diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..c01124d
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(mkdir:*)"
+ ]
+ }
+}
diff --git a/app/api/payment/confirm/route.ts b/app/api/payment/confirm/route.ts
new file mode 100644
index 0000000..d0b5dcf
--- /dev/null
+++ b/app/api/payment/confirm/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const { paymentKey, orderId, amount } = await request.json();
+
+ if (!paymentKey || !orderId || !amount) {
+ return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
+ }
+
+ // 1. Supabase에서 order 확인
+ const supabase = await createClient();
+ const { data: order, error: orderFetchError } = await supabase
+ .from('orders')
+ .select('*')
+ .eq('id', orderId)
+ .single();
+
+ if (orderFetchError || !order) {
+ return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
+ }
+ if (order.amount !== amount) {
+ return NextResponse.json({ error: '결제 금액 불일치' }, { status: 400 });
+ }
+ if (order.status === 'paid') {
+ return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
+ }
+
+ // 2. 토스페이먼츠 서버 승인
+ const secretKey = process.env.TOSS_SECRET_KEY!;
+ const encoded = Buffer.from(`${secretKey}:`).toString('base64');
+
+ const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${encoded}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ paymentKey, orderId, amount }),
+ });
+
+ if (!tossRes.ok) {
+ const err = await tossRes.json();
+ return NextResponse.json({ error: err.message || '토스 승인 실패' }, { status: 400 });
+ }
+
+ const tossData = await tossRes.json();
+
+ // 3. orders 상태 paid로 업데이트
+ const { error: updateError } = await supabase
+ .from('orders')
+ .update({ status: 'paid' })
+ .eq('id', orderId);
+
+ if (updateError) {
+ console.error('Order update error:', updateError);
+ return NextResponse.json({ error: '주문 상태 업데이트 실패: ' + updateError.message }, { status: 500 });
+ }
+
+ // 4. payments 레코드 생성
+ const { error: paymentError } = await supabase.from('payments').insert({
+ user_id: order.user_id,
+ order_id: orderId,
+ product_name: order.metadata?.product_name ?? order.product_id,
+ amount: order.amount,
+ status: 'paid',
+ pg_provider: 'toss',
+ pg_payment_key: paymentKey,
+ });
+
+ if (paymentError) {
+ console.error('Payment insert error:', paymentError);
+ return NextResponse.json({ error: '결제 내역 저장 실패: ' + paymentError.message }, { status: 500 });
+ }
+
+ return NextResponse.json({ success: true, data: tossData });
+ } catch (error: unknown) {
+ console.error('Payment confirm error:', error);
+ return NextResponse.json({ error: '서버 오류' }, { status: 500 });
+ }
+}
diff --git a/app/api/saju/analyze/route.ts b/app/api/saju/analyze/route.ts
new file mode 100644
index 0000000..9487f38
--- /dev/null
+++ b/app/api/saju/analyze/route.ts
@@ -0,0 +1,121 @@
+
+import { NextResponse } from 'next/server';
+import OpenAI from 'openai';
+import { createSajuPrompt } from '@/lib/saju-ai-prompt';
+import { performFullAnalysis } from '@/lib/ai-interpretation';
+
+export const runtime = 'nodejs';
+
+const MOCK_INTERPRETATION = `
+## 1. 일간 분석과 타고난 기질
+(API 키 문제 또는 할당량 초과로 인해 예시 데이터를 보여드립니다.)
+귀하는 **갑목(甲木)** 일간으로 태어나, 마치 곧게 뻗은 소나무와 같은 기상을 지니고 있다. 리더십이 강하고 추진력이 뛰어나며, 한번 마음먹은 일은 끝까지 해내는 뚝심이 있다.
+
+## 2. 오행 균형과 용신 기반 개운법
+사주에서 **화(火)** 기운이 부족하여 표현력이 다소 약할 수 있다. 붉은색 계통의 옷이나 소품을 활용하고, 밝은 곳에서 활동하는 것이 운을 트이게 한다.
+
+## 3. 지지 상호작용 해석
+지지 간의 상호작용을 살펴보면, 특별한 합충형이 발견된다.
+
+## 4. 신살이 삶에 미치는 영향
+역마살이 사주에 자리하고 있어 이동과 변동이 많은 삶을 살게 된다.
+
+## 5. 재물운과 금전 흐름
+재물창고인 **진토(辰土)**를 깔고 있어 기본적으로 재복은 타고났다. 다만, 돈을 버는 것보다 지키는 힘이 약할 수 있으니 저축 습관이 중요하다.
+
+## 6. 직업 적성과 진로
+교육, 출판, 건축, 디자인 등 창조적이고 독립적인 분야에서 두각을 나타낼 수 있다.
+
+## 7. 애정운과 결혼
+자존심이 강해 상대방에게 굽히지 않으려는 성향이 있다. 배우자와의 관계에서는 조금 더 부드러운 태도가 필요하다.
+
+## 8. 건강운
+간, 담낭, 신경계 통증에 유의해야 한다. 스트레스를 받으면 뭉치는 경향이 있으니 스트레칭과 요가를 추천한다.
+
+## 9. 현재 대운의 흐름과 기회/위기
+현재 대운은 인생의 전환점이다. 새로운 것을 시작하기보다는 기존의 것을 다지고 내실을 기하는 시기이다.
+
+## 10. 올해의 세운 분석
+올해는 귀인의 도움을 받을 수 있는 해이다. 주저하지 말고 주변에 도움을 요청하라.
+
+## 11. 인생의 황금기 예측
+40대 중반부터 50대 초반까지 인생의 가장 화려한 시기를 맞이할 것으로 보인다.
+
+## 12. 종합 조언
+"서두르지 않아도 봄은 온다." 조급해하지 말고 때를 기다리는 지혜가 필요하다.
+`;
+
+// 사용 가능한 모델 우선순위 (gpt-4o → gpt-4o-mini 폴백)
+const MODELS = ['gpt-4o', 'gpt-4o-mini'] as const;
+
+export async function POST(request: Request) {
+ try {
+ const { saju, daeun, daeunList, gender } = await request.json();
+
+ // 종합 분석 수행
+ let analysis;
+ try {
+ analysis = performFullAnalysis(saju);
+ } catch (analysisError: any) {
+ console.error('Analysis calculation error:', analysisError.message);
+ return NextResponse.json(
+ { error: '사주 분석 계산 중 오류가 발생했습니다: ' + analysisError.message },
+ { status: 500 }
+ );
+ }
+
+ if (!process.env.OPENAI_API_KEY) {
+ console.warn('OpenAI API Key is missing');
+ return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
+ }
+
+ const openai = new OpenAI({
+ apiKey: process.env.OPENAI_API_KEY,
+ });
+
+ const prompt = createSajuPrompt(saju, daeun, gender, analysis, daeunList || []);
+
+ // 모델 폴백: gpt-4o 실패 시 gpt-4o-mini로 재시도
+ let interpretation: string | null = null;
+ let usedModel = '';
+
+ for (const model of MODELS) {
+ try {
+ console.log(`Generating saju analysis with model: ${model}`);
+ const completion = await openai.chat.completions.create({
+ messages: [{ role: 'system', content: prompt }],
+ model,
+ max_tokens: model === 'gpt-4o' ? 8192 : 4096,
+ temperature: 0.75,
+ });
+ interpretation = completion.choices[0].message.content;
+ usedModel = model;
+ console.log(`Successfully generated with model: ${model}`);
+ break;
+ } catch (modelError: any) {
+ console.warn(`Model ${model} failed:`, modelError.message || modelError.status);
+ if (modelError.status === 401) {
+ console.warn('OpenAI API Key is invalid (401). Returning mock data.');
+ return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
+ }
+ if (modelError.status === 429 || (modelError.error && modelError.error.code === 'insufficient_quota')) {
+ console.warn('OpenAI Quota Exceeded. Returning mock data.');
+ return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
+ }
+ if (model === MODELS[MODELS.length - 1]) {
+ throw modelError;
+ }
+ console.log(`Falling back to next model...`);
+ }
+ }
+
+ return NextResponse.json({ interpretation, analysis });
+ } catch (error: any) {
+ console.error('Error generating saju interpretation:', error.message || error);
+
+ return NextResponse.json(
+ { error: error.message || 'Failed to generate interpretation' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/saju/calculate/route.ts b/app/api/saju/calculate/route.ts
new file mode 100644
index 0000000..ddc8a6f
--- /dev/null
+++ b/app/api/saju/calculate/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
+const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
+
+export async function POST(request: NextRequest) {
+ if (!SAJU_ENGINE_URL) {
+ return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(`${SAJU_ENGINE_URL}/saju/calculate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15000), // 15초 타임아웃
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: data.detail || '사주 계산 실패' },
+ { status: response.status }
+ );
+ }
+
+ return NextResponse.json(data);
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name === 'TimeoutError') {
+ return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
+ }
+ console.error('사주 계산 프록시 오류:', error);
+ return NextResponse.json({ error: '서버 오류' }, { status: 500 });
+ }
+}
diff --git a/app/api/saju/lotto/route.ts b/app/api/saju/lotto/route.ts
new file mode 100644
index 0000000..9cf933e
--- /dev/null
+++ b/app/api/saju/lotto/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
+const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
+
+export async function POST(request: NextRequest) {
+ if (!SAJU_ENGINE_URL) {
+ return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(`${SAJU_ENGINE_URL}/saju/lotto`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: data.detail || '로또 번호 생성 실패' },
+ { status: response.status }
+ );
+ }
+
+ return NextResponse.json(data);
+ } catch (error: unknown) {
+ if (error instanceof Error && error.name === 'TimeoutError') {
+ return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
+ }
+ console.error('로또 번호 생성 프록시 오류:', error);
+ return NextResponse.json({ error: '서버 오류' }, { status: 500 });
+ }
+}
diff --git a/app/api/saju/save-interpretation/route.ts b/app/api/saju/save-interpretation/route.ts
new file mode 100644
index 0000000..ba505d8
--- /dev/null
+++ b/app/api/saju/save-interpretation/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+
+export async function POST(request: NextRequest) {
+ try {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
+ }
+
+ const { interpretation, birthKey } = await request.json();
+
+ if (!interpretation || !birthKey) {
+ return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
+ }
+
+ // 기존 레코드 확인 (중복 저장 방지)
+ const { data: existing } = await supabase
+ .from('saju_records')
+ .select('id')
+ .eq('user_id', user.id)
+ .eq('is_paid', true)
+ .contains('saju_data', birthKey)
+ .maybeSingle();
+
+ if (existing) {
+ // 기존 레코드 업데이트
+ await supabase
+ .from('saju_records')
+ .update({ interpretation })
+ .eq('id', existing.id);
+ } else {
+ // 새 레코드 생성
+ await supabase.from('saju_records').insert({
+ user_id: user.id,
+ saju_data: birthKey,
+ interpretation,
+ is_paid: true,
+ });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Save interpretation error:', error);
+ return NextResponse.json({ error: '저장 실패' }, { status: 500 });
+ }
+}
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
new file mode 100644
index 0000000..27401f8
--- /dev/null
+++ b/app/auth/callback/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from 'next/server';
+import { createClient } from '@/lib/supabase/server';
+
+export async function GET(request: Request) {
+ const { searchParams, origin } = new URL(request.url);
+ const code = searchParams.get('code');
+ const next = searchParams.get('next') ?? '/mypage';
+
+ if (code) {
+ const supabase = await createClient();
+ const { error } = await supabase.auth.exchangeCodeForSession(code);
+ if (!error) {
+ const forwardedHost = request.headers.get('x-forwarded-host');
+ const isLocalEnv = process.env.NODE_ENV === 'development';
+ if (isLocalEnv) {
+ return NextResponse.redirect(`${origin}${next}`);
+ } else if (forwardedHost) {
+ return NextResponse.redirect(`https://${forwardedHost}${next}`);
+ } else {
+ return NextResponse.redirect(`${origin}${next}`);
+ }
+ }
+ }
+
+ return NextResponse.redirect(`${origin}/login?error=auth-callback-error`);
+}
diff --git a/app/components/DashboardShell.tsx b/app/components/DashboardShell.tsx
index e1986f0..3aeab22 100644
--- a/app/components/DashboardShell.tsx
+++ b/app/components/DashboardShell.tsx
@@ -1,10 +1,20 @@
'use client';
import { useState } from 'react';
+import { usePathname } from 'next/navigation';
import Sidebar from './Sidebar';
+const AUTH_PATHS = ['/login', '/signup'];
+
export default function DashboardShell({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
+ const pathname = usePathname();
+
+ const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p));
+
+ if (isAuthPage) {
+ return <>{children}>;
+ }
return (
diff --git a/app/components/PaymentButton.tsx b/app/components/PaymentButton.tsx
new file mode 100644
index 0000000..ad7df11
--- /dev/null
+++ b/app/components/PaymentButton.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { createClient } from '@/lib/supabase/client';
+import { PRODUCTS } from '@/lib/products';
+
+interface PaymentButtonProps {
+ productId: string;
+ className?: string;
+ children: React.ReactNode;
+ returnUrl?: string;
+}
+
+export default function PaymentButton({ productId, className, children, returnUrl }: PaymentButtonProps) {
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+ const supabase = createClient();
+ const product = PRODUCTS[productId];
+
+ const handlePayment = async () => {
+ setLoading(true);
+ try {
+ // 1. 로그인 확인
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ router.push('/login?next=' + encodeURIComponent(window.location.pathname));
+ return;
+ }
+
+ // 2. 프로필 없으면 생성 (Google OAuth 등으로 트리거 미실행된 경우 대비)
+ await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
+
+ // 3. Supabase에 order 생성
+ const orderId = crypto.randomUUID();
+ const { error: orderError } = await supabase
+ .from('orders')
+ .insert({
+ id: orderId,
+ user_id: user.id,
+ product_id: productId,
+ amount: product.price,
+ status: 'pending',
+ metadata: { product_name: product.name },
+ });
+
+ if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
+
+ // 4. 토스페이먼츠 결제창 호출
+ const { loadTossPayments } = await import('@tosspayments/tosspayments-sdk');
+ const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!;
+ const tossPayments = await loadTossPayments(clientKey);
+
+ const payment = tossPayments.payment({
+ customerKey: user.id,
+ });
+
+ await payment.requestPayment({
+ method: 'CARD',
+ amount: {
+ currency: 'KRW',
+ value: product.price,
+ },
+ orderId,
+ orderName: product.name,
+ successUrl: `${window.location.origin}/payment/success${returnUrl ? '?returnUrl=' + encodeURIComponent(returnUrl) : ''}`,
+ failUrl: `${window.location.origin}/payment/fail`,
+ customerEmail: user.email,
+ });
+ } catch (err: unknown) {
+ const error = err as { code?: string; message?: string };
+ // 사용자가 결제창 닫은 경우는 무시
+ if (error?.code !== 'USER_CANCEL') {
+ alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
+ console.error(err);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!product) return null;
+
+ return (
+
+ );
+}
diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx
index 1ffdc53..d5d80ea 100644
--- a/app/components/Sidebar.tsx
+++ b/app/components/Sidebar.tsx
@@ -1,7 +1,9 @@
'use client';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { usePathname, useRouter } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { createClient } from '@/lib/supabase/client';
const navItems = [
{
@@ -57,6 +59,17 @@ const navItems = [
label: '업무 자동화',
desc: 'RPA 개발',
},
+ {
+ href: '/saju',
+ icon: (
+
+ ),
+ label: 'AI 사주 분석',
+ desc: '사주팔자 + AI 해석',
+ badge: 'NEW',
+ },
{
href: '/freelance',
icon: (
@@ -76,6 +89,28 @@ interface SidebarProps {
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const pathname = usePathname();
+ const router = useRouter();
+ const [userEmail, setUserEmail] = useState
(null);
+ const supabase = createClient();
+
+ useEffect(() => {
+ supabase.auth.getUser().then(({ data }) => {
+ setUserEmail(data.user?.email ?? null);
+ });
+
+ const { data: { subscription } } = supabase.auth.onAuthStateChange((_, session) => {
+ setUserEmail(session?.user?.email ?? null);
+ });
+
+ return () => subscription.unsubscribe();
+ }, []);
+
+ const handleLogout = async () => {
+ await supabase.auth.signOut();
+ router.push('/');
+ router.refresh();
+ onClose();
+ };
return (
<>
@@ -116,7 +151,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
메뉴
{navItems.map((item) => {
- const isActive = pathname === item.href;
+ const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
return (
- {/* Bottom: Developer profile */}
+ {/* Bottom: 로그인 상태 */}
-
-
- 쟁
+ {userEmail ? (
+ /* 로그인 상태 */
+
+
+
+ {userEmail[0].toUpperCase()}
+
+
+
+
+
-
-
쟁토리
-
시니어 백엔드 개발자
+ ) : (
+ /* 비로그인 상태 */
+
-
-
+ )}
>
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..7f1b9da
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+import { createClient } from '@/lib/supabase/client';
+import { Suspense } from 'react';
+
+function LoginForm() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [isSignUp, setIsSignUp] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState('');
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const supabase = createClient();
+
+ useEffect(() => {
+ if (searchParams.get('error')) {
+ setMessage('인증 중 오류가 발생했습니다. 다시 시도해주세요.');
+ }
+ // 이미 로그인된 경우 리다이렉트
+ supabase.auth.getUser().then(({ data }) => {
+ if (data.user) router.push('/mypage');
+ });
+ }, []);
+
+ const handleAuth = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setMessage('');
+
+ if (isSignUp) {
+ const { data, error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ emailRedirectTo: `${window.location.origin}/auth/callback`,
+ },
+ });
+ if (error) {
+ setMessage('회원가입 실패: ' + error.message);
+ } else if (data.user?.identities?.length === 0) {
+ setMessage('이미 가입된 이메일입니다. 로그인해주세요.');
+ setIsSignUp(false);
+ } else {
+ setMessage('가입 완료! 이메일 인증 링크를 확인해주세요.');
+ }
+ } else {
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) {
+ setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
+ } else {
+ router.push('/mypage');
+ router.refresh();
+ }
+ }
+ setLoading(false);
+ };
+
+ const handleGoogleLogin = async () => {
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}/auth/callback` },
+ });
+ if (error) setMessage('Google 로그인 오류: ' + error.message);
+ };
+
+ return (
+
+ {/* 배경 장식 */}
+
+
+
+ {/* 로고 */}
+
+
+
+ 쟁
+
+
+
쟁승메이드
+
Premium Dev Services
+
+
+
+
+ {/* 카드 */}
+
+
+
+ {isSignUp ? '회원가입' : '로그인'}
+
+
+ {isSignUp
+ ? '가입 후 사주 기록, 결제 내역을 관리하세요'
+ : '사주 기록·결제·의뢰 내역을 확인하세요'}
+
+
+
+ {/* 오류/성공 메시지 */}
+ {message && (
+
+ {message}
+
+ )}
+
+ {/* 이메일/비밀번호 폼 */}
+
+
+ {/* 전환 링크 */}
+
+
+
+
+ {/* 구분선 */}
+
+
+ {/* 구글 로그인 */}
+
+
+
+ {/* 홈으로 */}
+
+
+ ← 홈으로 돌아가기
+
+
+
+
+ );
+}
+
+export default function LoginPage() {
+ return (
+
+
+
+ }>
+
+
+ );
+}
diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx
new file mode 100644
index 0000000..bdce876
--- /dev/null
+++ b/app/mypage/page.tsx
@@ -0,0 +1,390 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { createClient } from '@/lib/supabase/client';
+import type { User } from '@supabase/supabase-js';
+
+function buildSajuResultUrl(rec: SajuRecord) {
+ const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
+ if (!birth_year || !birth_month || !birth_day) return '/saju/input';
+ let url = `/saju/result?year=${birth_year}&month=${birth_month}&day=${birth_day}&gender=${gender}&calendarType=solar`;
+ if (birth_hour != null) url += `&hour=${birth_hour}`;
+ return url;
+}
+
+type Tab = 'profile' | 'saju' | 'payments' | 'orders';
+
+interface SajuRecord {
+ id: number;
+ created_at: string;
+ saju_data: {
+ birth_year: number;
+ birth_month: number;
+ birth_day: number;
+ birth_hour?: number;
+ gender: string;
+ };
+ interpretation: string | null;
+ is_paid: boolean;
+}
+
+interface Payment {
+ id: string;
+ created_at: string;
+ amount: number;
+ status: string;
+ product_name: string;
+}
+
+interface Order {
+ id: string;
+ created_at: string;
+ service: string;
+ message: string;
+ status: string;
+}
+
+export default function MyPage() {
+ const router = useRouter();
+ const supabase = createClient();
+ const [user, setUser] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [tab, setTab] = useState('profile');
+ const [sajuRecords, setSajuRecords] = useState([]);
+ const [payments, setPayments] = useState([]);
+ const [orders, setOrders] = useState([]);
+
+ useEffect(() => {
+ async function init() {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
+ router.push('/login');
+ return;
+ }
+ setUser(user);
+
+ // 사주 기록 조회 (테이블 있을 때 동작)
+ const { data: saju } = await supabase
+ .from('saju_records')
+ .select('*')
+ .eq('user_id', user.id)
+ .order('created_at', { ascending: false })
+ .limit(20);
+ setSajuRecords(saju || []);
+
+ // 결제 내역 조회
+ const { data: pay } = await supabase
+ .from('payments')
+ .select('*')
+ .eq('user_id', user.id)
+ .order('created_at', { ascending: false })
+ .limit(20);
+ setPayments(pay || []);
+
+ // 의뢰 내역 조회
+ const { data: ord } = await supabase
+ .from('contact_requests')
+ .select('*')
+ .eq('user_id', user.id)
+ .order('created_at', { ascending: false })
+ .limit(20);
+ setOrders(ord || []);
+
+ setLoading(false);
+ }
+ init();
+ }, []);
+
+ const handleLogout = async () => {
+ await supabase.auth.signOut();
+ router.push('/');
+ router.refresh();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!user) return null;
+
+ const tabs: { key: Tab; label: string; count?: number }[] = [
+ { key: 'profile', label: '내 정보' },
+ { key: 'saju', label: '사주 기록', count: sajuRecords.length },
+ { key: 'payments', label: '결제 내역', count: payments.length },
+ { key: 'orders', label: '의뢰 내역', count: orders.length },
+ ];
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+ {user.email?.[0].toUpperCase()}
+
+
+
{user.email}
+
+ 가입일: {new Date(user.created_at).toLocaleDateString('ko-KR')}
+
+
+
+
+
+
+
+
+
+
+ {/* 탭 */}
+
+ {tabs.map((t) => (
+
+ ))}
+
+
+ {/* 탭 콘텐츠 */}
+
+ {/* 내 정보 */}
+ {tab === 'profile' && (
+
+
+
+
+ 계정 정보
+
+
+
+ 이메일
+ {user.email}
+
+
+ 로그인 방법
+
+ {user.app_metadata?.provider === 'google' ? 'Google' : '이메일'}
+
+
+
+ 가입일
+
+ {new Date(user.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
+
+
+
+
+
+
+
+ )}
+
+ {/* 사주 기록 */}
+ {tab === 'saju' && (
+
+ {sajuRecords.length === 0 ? (
+
+ ) : (
+
+ {sajuRecords.map((rec) => (
+
+
+
+
{new Date(rec.created_at).toLocaleDateString('ko-KR')}
+
+ {rec.saju_data?.birth_year ?? '?'}년{' '}
+ {rec.saju_data?.birth_month ?? '?'}월{' '}
+ {rec.saju_data?.birth_day ?? '?'}일생
+
+
+ {rec.saju_data?.gender === 'male' ? '남성' : '여성'}
+ {rec.saju_data?.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
+
+
+
+ {rec.is_paid ? '유료' : '무료'}
+
+
+ {rec.interpretation && (
+
+ {rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
+
+ )}
+
+ {rec.is_paid && rec.interpretation ? 'AI 해석 다시 보기 →' : '결과 보기 →'}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* 결제 내역 */}
+ {tab === 'payments' && (
+
+ {payments.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 서비스 |
+ 금액 |
+ 상태 |
+ 일시 |
+
+
+
+ {payments.map((p, i) => (
+
+ | {p.product_name} |
+ ₩{p.amount?.toLocaleString()} |
+
+
+ {p.status === 'paid' ? '결제완료' : p.status}
+
+ |
+
+ {new Date(p.created_at).toLocaleDateString('ko-KR')}
+ |
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {/* 의뢰 내역 */}
+ {tab === 'orders' && (
+
+ {orders.length === 0 ? (
+
+ ) : (
+
+ {orders.map((o) => (
+
+
+
{o.service}
+
+ {o.status === 'completed' ? '완료' : o.status === 'in_progress' ? '진행중' : '대기중'}
+
+
+
{o.message}
+
{new Date(o.created_at).toLocaleDateString('ko-KR')}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+function EmptyState({
+ icon, title, desc, linkHref, linkLabel,
+}: {
+ icon: string; title: string; desc: string; linkHref: string; linkLabel: string;
+}) {
+ return (
+
+
{icon}
+
{title}
+
{desc}
+
+ {linkLabel} →
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 4660ab0..bbce250 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -301,6 +301,47 @@ export default function Home() {
+
+
+ {/* ─ AI 사주 분석 ─ */}
+
+
+
+
+
NEW
+
+
+
+
AI SAJU ANALYTICS
+
AI 사주 분석
+
전통 명리학과 GPT-4o의 만남 — 12가지 항목 상세 해석
+
+
+
+
+
사주팔자 원국 계산부터 신강/신약 분석, 용신·희신, 대운까지 — AI가 따뜻하고 정확하게 해석해드립니다.
+
+ {['전통 사주팔자 계산', 'AI 12가지 항목 해석', '무료 기본 · 유료 상세'].map(f => (
+
+ ))}
+
+
+
+ 무료 체험 / 상세 ₩4,900
+ 1회
+
+
자세히 보기 →
+
+
+
{/* ─ Freelance CTA ─ */}
diff --git a/app/payment/fail/page.tsx b/app/payment/fail/page.tsx
new file mode 100644
index 0000000..970588a
--- /dev/null
+++ b/app/payment/fail/page.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { Suspense } from 'react';
+import { useSearchParams } from 'next/navigation';
+import Link from 'next/link';
+
+function FailContent() {
+ const params = useSearchParams();
+ const message = params.get('message') ?? '결제가 취소되었거나 실패했습니다.';
+ const code = params.get('code') ?? '';
+
+ return (
+
+
+
+ {code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
+
+
+ {code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
+
+
{message}
+
+
+
+ 홈으로
+
+
+
+ );
+}
+
+export default function PaymentFailPage() {
+ return (
+
+
+ );
+}
diff --git a/app/payment/success/page.tsx b/app/payment/success/page.tsx
new file mode 100644
index 0000000..12498ef
--- /dev/null
+++ b/app/payment/success/page.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import { Suspense, useEffect, useState } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import Link from 'next/link';
+
+function SuccessContent() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
+ const [errorMsg, setErrorMsg] = useState('');
+ const [productName, setProductName] = useState('');
+
+ useEffect(() => {
+ const paymentKey = params.get('paymentKey');
+ const orderId = params.get('orderId');
+ const amount = Number(params.get('amount'));
+ const returnUrl = params.get('returnUrl');
+
+ if (!paymentKey || !orderId || !amount) {
+ setStatus('error');
+ setErrorMsg('잘못된 접근입니다.');
+ return;
+ }
+
+ fetch('/api/payment/confirm', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paymentKey, orderId, amount }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.success) {
+ setProductName(data.data?.orderName ?? '');
+ setStatus('success');
+ if (returnUrl) {
+ router.replace(returnUrl);
+ }
+ } else {
+ setStatus('error');
+ setErrorMsg(data.error || '결제 승인에 실패했습니다.');
+ }
+ })
+ .catch(() => {
+ setStatus('error');
+ setErrorMsg('서버 오류가 발생했습니다. 결제 내역을 확인해주세요.');
+ });
+ }, []);
+
+ if (status === 'loading') {
+ return (
+
+ );
+ }
+
+ if (status === 'error') {
+ return (
+
+
+
결제 처리 실패
+
{errorMsg}
+
+
+ 결제 내역 확인
+
+
+ 홈으로 →
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ 결제 완료
+
+
결제가 완료되었습니다!
+ {productName && (
+
{productName}
+ )}
+
+ 마이페이지에서 결제 내역과 서비스 이용 현황을 확인하세요.
+
+
+
+ 결제 내역 확인 →
+
+
+ 홈으로
+
+
+
+ );
+}
+
+export default function PaymentSuccessPage() {
+ return (
+
+
+ );
+}
diff --git a/app/saju/components/SajuForm.tsx b/app/saju/components/SajuForm.tsx
new file mode 100644
index 0000000..3313da9
--- /dev/null
+++ b/app/saju/components/SajuForm.tsx
@@ -0,0 +1,220 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { lunarToSolar } from '@/lib/lunar-utils';
+
+export default function SajuForm() {
+ const router = useRouter();
+ const [year, setYear] = useState('');
+ const [month, setMonth] = useState('');
+ const [day, setDay] = useState('');
+ const [hour, setHour] = useState('');
+ const [calendarType, setCalendarType] = useState<'solar' | 'lunar'>('solar');
+ const [gender, setGender] = useState<'male' | 'female'>('male');
+ const [isLeapMonth, setIsLeapMonth] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!year || !month || !day) {
+ alert('생년월일을 모두 입력해주세요.');
+ return;
+ }
+
+ let finalYear = year;
+ let finalMonth = month;
+ let finalDay = day;
+
+ // 음력인 경우 양력으로 변환
+ if (calendarType === 'lunar') {
+ const solar = lunarToSolar(
+ parseInt(year),
+ parseInt(month),
+ parseInt(day),
+ isLeapMonth
+ );
+ finalYear = solar.year.toString();
+ finalMonth = solar.month.toString();
+ finalDay = solar.day.toString();
+ }
+
+ // URL 파라미터로 전달
+ const params = new URLSearchParams({
+ year: finalYear,
+ month: finalMonth,
+ day: finalDay,
+ gender,
+ calendarType,
+ originalYear: year,
+ originalMonth: month,
+ originalDay: day,
+ });
+
+ if (hour) {
+ params.append('hour', hour);
+ }
+
+ if (calendarType === 'lunar') {
+ params.append('isLeapMonth', isLeapMonth.toString());
+ }
+
+ router.push(`/saju/result?${params.toString()}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/saju/input/page.tsx b/app/saju/input/page.tsx
new file mode 100644
index 0000000..bc782b0
--- /dev/null
+++ b/app/saju/input/page.tsx
@@ -0,0 +1,43 @@
+import SajuForm from '../components/SajuForm';
+
+export default function SajuInputPage() {
+ return (
+
+ {/* Hero */}
+
+
+
+
+
+
+
+ AI 사주 분석 · 생년월일 입력
+
+
+ 생년월일을 입력해주세요
+
+
+ 정확한 생년월일과 태어난 시간을 입력하면
+ 더 정밀한 사주팔자를 계산할 수 있습니다.
+
+
+
+
+ {/* Form 영역 */}
+
+
+
+
+ 입력하신 정보는 사주 계산에만 사용되며 별도로 저장되지 않습니다.
+
+
+
+ );
+}
diff --git a/app/saju/page.tsx b/app/saju/page.tsx
new file mode 100644
index 0000000..8c0b82b
--- /dev/null
+++ b/app/saju/page.tsx
@@ -0,0 +1,341 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import PaymentButton from '../components/PaymentButton';
+import { createClient } from '@/lib/supabase/client';
+
+const faqItems = [
+ {
+ q: '사주팔자란 무엇인가요?',
+ a: '사주팔자(四柱八字)는 태어난 년·월·일·시의 네 기둥(四柱)에 각각 천간과 지지 두 글자씩 총 여덟 글자(八字)로 이루어진 동양의 전통 운명 분석 체계입니다.',
+ },
+ {
+ q: 'AI 해석은 어떻게 동작하나요?',
+ a: '전통 명리학 계산 로직(오행, 신강/신약, 용신/희신 등)으로 산출된 데이터를 GPT-4o에 전달하여 12개 항목의 상세 해석을 생성합니다. 기본 원국 분석은 무료이며, AI 상세 해석은 유료(₩4,900)로 제공됩니다.',
+ },
+ {
+ q: '태어난 시간을 모르면 어떻게 하나요?',
+ a: '시간을 모르더라도 년·월·일 세 기둥(三柱)만으로 사주를 계산할 수 있습니다. 다만 시주가 빠지면 세부 분석 정확도가 다소 낮아집니다.',
+ },
+ {
+ q: '음력으로 입력할 수 있나요?',
+ a: '네, 양력과 음력 모두 지원합니다. 음력을 선택하면 내부적으로 양력으로 변환하여 정확한 사주를 계산합니다. 윤달도 별도 선택이 가능합니다.',
+ },
+];
+
+interface SajuRecord {
+ id: number;
+ created_at: string;
+ saju_data: {
+ birth_year: number;
+ birth_month: number;
+ birth_day: number;
+ birth_hour?: number;
+ gender: string;
+ };
+ interpretation: string | null;
+ is_paid: boolean;
+}
+
+function buildResultUrl(rec: SajuRecord) {
+ const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
+ // null/undefined 값이 있으면 URL 생성 불가
+ if (!birth_year || !birth_month || !birth_day) return '/saju/input';
+ let url = `/saju/result?year=${birth_year}&month=${birth_month}&day=${birth_day}&gender=${gender}&calendarType=solar`;
+ if (birth_hour != null) url += `&hour=${birth_hour}`;
+ return url;
+}
+
+export default function SajuPage() {
+ const supabase = createClient();
+ const [paidRecords, setPaidRecords] = useState([]);
+ const [hasPaid, setHasPaid] = useState(false);
+ const [authChecked, setAuthChecked] = useState(false);
+
+ useEffect(() => {
+ async function fetchRecords() {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) { setAuthChecked(true); return; }
+
+ const { data: records } = await supabase
+ .from('saju_records')
+ .select('*')
+ .eq('user_id', user.id)
+ .eq('is_paid', true)
+ .order('created_at', { ascending: false })
+ .limit(2);
+
+ if (records && records.length > 0) {
+ setPaidRecords(records);
+ setHasPaid(true);
+ }
+ setAuthChecked(true);
+ }
+ fetchRecords();
+ }, []);
+
+ return (
+
+ {/* ─── Hero ─── */}
+
+
+
+
+
+
+
+
+ 전통 명리학 × AI 해석 · 무료 기본 분석 제공
+
+
+ AI가 분석하는
+
+ 사주팔자
+
+
+
+ 수천 년의 동양 명리학과 최신 AI 기술의 만남.
+ 태어난 순간의 우주적 에너지를 12가지 항목으로 해석해드립니다.
+
+
+ {/* 이전 기록 있으면 분기 버튼, 없으면 단일 CTA */}
+ {authChecked && hasPaid ? (
+
+ ) : (
+
+
+ 지금 바로 시작하기
+
+ )}
+
+
+
+
+
+
+ {/* ─── 이전 기록 섹션 (구매한 유저만) ─── */}
+ {hasPaid && paidRecords.length > 0 && (
+
+
+
MY RECORDS
+
이전 AI 사주 기록
+
결제한 사주 기록을 다시 확인하세요
+
+
+ {paidRecords.map((rec) => (
+
+
+
+
+ {new Date(rec.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
+
+
+ {rec.saju_data.birth_year ?? '?'}년{' '}
+ {rec.saju_data.birth_month ?? '?'}월{' '}
+ {rec.saju_data.birth_day ?? '?'}일생
+
+
+ {rec.saju_data.gender === 'male' ? '남성' : '여성'}
+ {rec.saju_data.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
+
+
+
+ AI 해석 완료
+
+
+ {rec.interpretation && (
+
+ {rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
+
+ )}
+
+ 다시 보기 →
+
+
+ ))}
+
+
+ )}
+
+ {/* ─── 바로 시작하기 CTA ─── */}
+
+
+
+
✨
+
지금 무료로 시작하세요
+
회원가입 없이, 생년월일만 입력하면 바로 확인 가능합니다
+
+ 사주 입력하러 가기 →
+
+
+
+
+ {/* ─── 무료 vs 유료 비교표 ─── */}
+
+
+
PRICING
+
무료 vs 유료 비교
+
기본 원국은 무료로, AI 상세 해석은 단 ₩4,900에
+
+
+
+ {/* 무료 */}
+
+
+
+ {[
+ '사주팔자 원국 (년·월·일·시주)',
+ '천간·지지·지장간 표',
+ '십성 및 십이운성',
+ '오행 분포 차트',
+ '지지 상호작용 (합·충·형)',
+ '일간 분석 요약',
+ ].map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
무료
+
회원가입 불필요
+
+ 무료로 시작하기
+
+
+
+
+ {/* 유료 */}
+
+
+ ₩4,900
+
+
+
+
+
+
AI PREMIUM
+
AI 상세 해석
+
+
+
+ {[
+ '무료 기본 분석 전체 포함',
+ '신강/신약 정밀 판단',
+ '용신·희신·기신 추정',
+ '대운 (10년 주기) 분석',
+ '올해 세운 흐름',
+ 'GPT-4o AI 12가지 상세 해석',
+ ].map((item) => (
+ -
+
+ {item}
+
+ ))}
+
+
+
₩4,900
+
1회 결제 · 영구 열람
+ {hasPaid ? (
+
+ 새 사주 입력하기 →
+
+ ) : (
+
+ AI 상세 해석 구매하기
+
+ )}
+
+
+
+
+
+ {/* ─── FAQ ─── */}
+
+
+
+ {faqItems.map((item, i) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/app/saju/result/SajuAISection.tsx b/app/saju/result/SajuAISection.tsx
new file mode 100644
index 0000000..49aa1cb
--- /dev/null
+++ b/app/saju/result/SajuAISection.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import PaymentButton from '@/app/components/PaymentButton';
+
+interface BirthKey {
+ birth_year: number;
+ birth_month: number;
+ birth_day: number;
+ birth_hour?: number;
+ gender: string;
+}
+
+interface SajuAISectionProps {
+ hasPaid: boolean;
+ savedInterpretation: string | null;
+ sajuData: object;
+ daeun: object | null;
+ daeunList: object[];
+ gender: string;
+ birthKey: BirthKey;
+ currentUrl: string;
+}
+
+export default function SajuAISection({
+ hasPaid,
+ savedInterpretation,
+ sajuData,
+ daeun,
+ daeunList,
+ gender,
+ birthKey,
+ currentUrl,
+}: SajuAISectionProps) {
+ const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>(
+ savedInterpretation ? 'done' : 'idle'
+ );
+ const [interpretation, setInterpretation] = useState(savedInterpretation ?? '');
+ const called = useRef(false);
+
+ useEffect(() => {
+ if (!hasPaid || savedInterpretation || called.current) return;
+ called.current = true;
+ setStatus('loading');
+
+ fetch('/api/saju/analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender }),
+ })
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.interpretation) {
+ setInterpretation(data.interpretation);
+ setStatus('done');
+ // birthKey 유효성 검사 후 저장 (NaN/null 방지)
+ const { birth_year, birth_month, birth_day } = birthKey;
+ if (
+ typeof birth_year === 'number' && !isNaN(birth_year) &&
+ typeof birth_month === 'number' && !isNaN(birth_month) &&
+ typeof birth_day === 'number' && !isNaN(birth_day)
+ ) {
+ fetch('/api/saju/save-interpretation', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ interpretation: data.interpretation, birthKey }),
+ }).catch(() => {});
+ }
+ } else {
+ setStatus('error');
+ }
+ })
+ .catch(() => setStatus('error'));
+ }, [hasPaid]);
+
+ // 미결제 상태
+ if (!hasPaid) {
+ return (
+
+
+
+
+ AI PREMIUM
+
+
AI 상세 해석 (12개 항목)
+
+ 성격, 재물운, 직업 적성, 애정운, 건강운, 대운 분석 등
+ GPT-4o가 생성하는 맞춤형 사주 해석을 받아보세요.
+
+
+ AI 해석 구매하기 · ₩4,900
+
+
+
+ );
+ }
+
+ // AI 생성 중
+ if (status === 'loading') {
+ return (
+
+
+
AI가 사주를 분석하는 중입니다...
+
약 20~30초 소요될 수 있습니다
+
+ );
+ }
+
+ // 오류
+ if (status === 'error') {
+ return (
+
+
AI 해석 생성에 실패했습니다.
+
+
+ );
+ }
+
+ // AI 해석 완료
+ return (
+
+
+
+
AI 상세 해석
+
+ 결제 완료
+
+
+
+ {interpretation}
+
+
+ );
+}
diff --git a/app/saju/result/page.tsx b/app/saju/result/page.tsx
new file mode 100644
index 0000000..0d11310
--- /dev/null
+++ b/app/saju/result/page.tsx
@@ -0,0 +1,584 @@
+import { calculateSaju } from '@/lib/saju-calculator';
+import Link from 'next/link';
+import { calculateDaeun, getCurrentDaeun, getDaeunDescription } from '@/lib/daeun-calculator';
+import { getCurrentSolarTerm, getSolarTermName, getSolarTermMonthBranch } from '@/lib/solar-terms';
+import { EARTHLY_BRANCHES_KR, FIVE_ELEMENTS_KR, FIVE_ELEMENTS } from '@/lib/saju-calculator';
+import { calculateElementScore, performFullAnalysis } from '@/lib/ai-interpretation';
+import { createClient } from '@/lib/supabase/server';
+import SajuAISection from './SajuAISection';
+
+interface PageProps {
+ searchParams: Promise<{
+ year: string;
+ month: string;
+ day: string;
+ hour?: string;
+ gender: 'male' | 'female';
+ calendarType: 'solar' | 'lunar';
+ originalYear?: string;
+ originalMonth?: string;
+ originalDay?: string;
+ isLeapMonth?: string;
+ }>;
+}
+
+export default async function SajuResultPage({ searchParams }: PageProps) {
+ const params = await searchParams;
+ const {
+ year, month, day, hour, gender, calendarType,
+ originalYear, originalMonth, originalDay, isLeapMonth
+ } = params;
+
+ const yearNum = parseInt(year, 10);
+ const monthNum = parseInt(month, 10);
+ const dayNum = parseInt(day, 10);
+ const hourNum = hour ? parseInt(hour, 10) : null;
+
+ // 필수 파라미터 누락 시 안전한 기본값 (NaN 방지)
+ if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) {
+ return (
+
+
+
잘못된 접근입니다. 생년월일을 다시 입력해주세요.
+
사주 입력하기
+
+
+ );
+ }
+
+ const inputYear = originalYear ? parseInt(originalYear) : yearNum;
+ const inputMonth = originalMonth ? parseInt(originalMonth) : monthNum;
+ const inputDay = originalDay ? parseInt(originalDay) : dayNum;
+ const isLunar = calendarType === 'lunar';
+ const isLeap = isLeapMonth === 'true';
+
+ const sajuData = calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
+
+ // 결제 여부 + 저장된 AI 해석 확인 (서버사이드)
+ let hasPaid = false;
+ let savedInterpretation: string | null = null;
+ try {
+ const supabase = await createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+ if (user) {
+ const { data: order } = await supabase
+ .from('orders')
+ .select('id')
+ .eq('user_id', user.id)
+ .eq('product_id', 'saju_detail')
+ .eq('status', 'paid')
+ .maybeSingle();
+ hasPaid = !!order;
+
+ if (hasPaid) {
+ const birthKey: Record = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender };
+ if (hourNum !== null) birthKey.birth_hour = hourNum;
+ const { data: record } = await supabase
+ .from('saju_records')
+ .select('interpretation')
+ .eq('user_id', user.id)
+ .eq('is_paid', true)
+ .contains('saju_data', birthKey)
+ .maybeSingle();
+ savedInterpretation = record?.interpretation ?? null;
+ }
+ }
+ } catch {
+ // 인증 오류 시 무시 (미로그인)
+ }
+
+ // 절기 정보
+ const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
+ const solarTermName = getSolarTermName(solarTermIndex);
+ const monthBranchIndex = getSolarTermMonthBranch(yearNum, monthNum, dayNum);
+ const monthBranchName = EARTHLY_BRANCHES_KR[monthBranchIndex];
+
+ // 종합 분석 수행
+ const analysis = performFullAnalysis(sajuData);
+ const elementScores = analysis.elementScores;
+
+ // 대운 계산
+ const daeunList = calculateDaeun(
+ yearNum, monthNum, dayNum, gender,
+ sajuData.month.stem, sajuData.month.branch
+ );
+ const currentYear = new Date().getFullYear();
+ const currentDaeun = getCurrentDaeun(daeunList, currentYear);
+
+ // 오행 색상 매핑
+ const elementColors: { [key: string]: string } = {
+ '木': 'text-green-700', '火': 'text-red-600', '土': 'text-yellow-700',
+ '金': 'text-amber-600', '水': 'text-blue-700',
+ };
+ const elementBgColors: { [key: string]: string } = {
+ '木': 'bg-green-50 border-green-400', '火': 'bg-red-50 border-red-400',
+ '土': 'bg-yellow-50 border-yellow-400', '金': 'bg-amber-50 border-amber-400',
+ '水': 'bg-blue-50 border-blue-400',
+ };
+
+ // 띠 계산
+ const zodiacAnimals = ['쥐', '소', '호랑이', '토끼', '용', '뱀', '말', '양', '원숭이', '닭', '개', '돼지'];
+ const zodiacIndex = (yearNum - 4) % 12;
+ const zodiacAnimal = zodiacAnimals[zodiacIndex >= 0 ? zodiacIndex : zodiacIndex + 12];
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+ 사주팔자 감정서
+
+
사주팔자 분석 결과
+
전통 명리학과 AI 기술의 만남
+
+
+
+
+
+
+ {/* 사이드바 - 기본 정보 */}
+
+
+ {/* 메인 콘텐츠 */}
+
+
+ {/* 사주팔자 표 */}
+
+
사주팔자 (四柱八字)
+
+
+
+
+
+ | 구분 |
+ {sajuData.hour && 시주 | }
+ 일주 |
+ 월주 |
+ 년주 |
+
+
+
+ {/* 천간 */}
+
+ | 천간 |
+ {sajuData.hour && (
+
+ {sajuData.hour.stem}
+ {sajuData.hour.stemKr}
+ |
+ )}
+
+ {sajuData.day.stem}
+ {sajuData.day.stemKr}
+ 일간
+ |
+
+ {sajuData.month.stem}
+ {sajuData.month.stemKr}
+ |
+
+ {sajuData.year.stem}
+ {sajuData.year.stemKr}
+ |
+
+
+ {/* 지지 */}
+
+ | 지지 |
+ {sajuData.hour && (
+
+ {sajuData.hour.branch}
+ {sajuData.hour.branchKr}
+ |
+ )}
+
+ {sajuData.day.branch}
+ {sajuData.day.branchKr}
+ |
+
+ {sajuData.month.branch}
+ {sajuData.month.branchKr}
+ |
+
+ {sajuData.year.branch}
+ {sajuData.year.branchKr}
+ |
+
+
+ {/* 지장간 */}
+
+ |
+ 지장간
+ 숨은 천간
+ |
+ {(() => {
+ const pillars = sajuData.hour
+ ? [analysis.hiddenStems.find(h => h.pillar === '시주'), analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')]
+ : [analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')];
+ return pillars.map((h, idx) => (
+
+ {h && (
+
+ {h.stems.map((s, si) => (
+
+ {s.stemKr}
+
+ ))}
+
+ )}
+ |
+ ));
+ })()}
+
+
+ {/* 십성 */}
+
+ | 십성 |
+ {sajuData.hour && (
+
+ {sajuData.hour.tenGod}
+ |
+ )}
+
+ {sajuData.day.tenGod}
+ |
+
+ {sajuData.month.tenGod}
+ |
+
+ {sajuData.year.tenGod}
+ |
+
+
+ {/* 십이운성 */}
+
+ | 십이운성 |
+ {sajuData.hour && (
+
+ {sajuData.hour.fortune}
+ |
+ )}
+
+ {sajuData.day.fortune}
+ |
+
+ {sajuData.month.fortune}
+ |
+
+ {sajuData.year.fortune}
+ |
+
+
+
+
+
+ {/* 지지 상호작용 */}
+ {analysis.branchInteractions.length > 0 && (
+
+
지지 상호작용
+
+ {analysis.branchInteractions.map((inter, idx) => {
+ const isPositive = inter.type.includes('합');
+ const isNegative = inter.type.includes('충') || inter.type.includes('형');
+ const colorClass = isPositive
+ ? 'bg-emerald-50 border-emerald-400 text-emerald-800'
+ : isNegative
+ ? 'bg-red-50 border-red-400 text-red-800'
+ : 'bg-amber-50 border-amber-400 text-amber-800';
+ return (
+
+ {inter.type} {inter.branchesKr.join('')}
+ {inter.resultElement && ` → ${FIVE_ELEMENTS_KR[inter.resultElement as keyof typeof FIVE_ELEMENTS_KR]}`}
+
+ );
+ })}
+
+
+ )}
+
+ {/* 오행 균형 */}
+
+
오행 균형
+
+ {Object.entries(elementScores).map(([element, score]) => (
+
+
{element}
+
+ {FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR]}
+
+
+
{score}%
+
+ ))}
+
+
+
+
+ {/* 분석 카드 그리드 */}
+
+ {/* 신강/신약 + 용신 */}
+
+
일간 세력 분석
+
+
+ {analysis.dayMasterStrength.result}
+
+ 점수: {analysis.dayMasterStrength.score}
+
+
+ {analysis.dayMasterStrength.reasons.map((r, i) => (
+ -
+ -
+ {r}
+
+ ))}
+
+
+
+
용신 / 희신 / 기신
+
+
+ 용신: {analysis.yongShin.yongShinKr}
+
+
+ 희신: {analysis.yongShin.heeShinKr}
+
+
+ 기신: {analysis.yongShin.giShinKr}
+
+
+
{analysis.yongShin.explanation}
+
+
+
+ {/* 신살 + 공망 */}
+
+
신살 (神煞)
+ {analysis.shinsal.length > 0 ? (
+
+ {analysis.shinsal.map((s, i) => (
+
+
+ {s.name}
+
+
+
+ {s.pillar} {s.branchKr}
+
+
{s.description}
+
+
+ ))}
+
+ ) : (
+
특별한 신살이 발견되지 않았습니다.
+ )}
+
+
+
공망 (空亡)
+
+ {analysis.gongmang.branchesKr.map((bk, i) => (
+
+ {bk}
+
+ ))}
+
+
{analysis.gongmang.description}
+
+
+ {/* 세운 정보 */}
+
+
+ {analysis.seun.year}년 세운
+
+
+
+ {analysis.seun.stemKr}{analysis.seun.branchKr}
+
+ {analysis.seun.elementKr} 기운
+
+ {analysis.seun.interactions.length > 0 && (
+
+ {analysis.seun.interactions.map((si, i) => (
+
+ {si.type} {si.branchesKr.join('')}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* AI 상세 해석 섹션 */}
+ {(() => {
+ const birthKey = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender, ...(hourNum !== null ? { birth_hour: hourNum } : {}) };
+ const currentUrl = `/saju/result?year=${yearNum}&month=${monthNum}&day=${dayNum}${hourNum !== null ? `&hour=${hourNum}` : ''}&gender=${gender}&calendarType=${calendarType}${originalYear ? `&originalYear=${originalYear}&originalMonth=${originalMonth}&originalDay=${originalDay}` : ''}${isLeap ? '&isLeapMonth=true' : ''}`;
+ return (
+
+ );
+ })()}
+
+ {/* 대운 */}
+
+
+ 대운 (大運) — 10년 주기 운세
+
+
+ {currentDaeun && (
+
+
현재 대운
+
+
+ {currentDaeun.stem}{currentDaeun.branch}
+
+
+ {currentDaeun.stemKr}{currentDaeun.branchKr}
+
+
+ {currentDaeun.age}세 ~ {currentDaeun.age + 9}세 ({currentDaeun.startYear} ~ {currentDaeun.endYear}년)
+
+
+
+ {getDaeunDescription(currentDaeun, sajuData.day.stem)}
+
+
+ )}
+
+
+ {daeunList.map((daeun, index) => {
+ const isCurrent = currentDaeun &&
+ daeun.startYear === currentDaeun.startYear &&
+ daeun.endYear === currentDaeun.endYear;
+
+ return (
+
+
+
+ {daeun.stem}{daeun.branch}
+
+
+ {daeun.stemKr}{daeun.branchKr}
+
+
+ {daeun.age}세 ~ {daeun.age + 9}세
+
+
+ {daeun.startYear} ~ {daeun.endYear}
+
+ {isCurrent && (
+
+
+ 현재
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/services/lotto/page.tsx b/app/services/lotto/page.tsx
index 1631244..8ee30a0 100644
--- a/app/services/lotto/page.tsx
+++ b/app/services/lotto/page.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '../../components/ContactModal';
+import PaymentButton from '../../components/PaymentButton';
const CHECKLIST = [
'구독 플랜 선택 (기본 / 프리미엄 / 연간)',
@@ -24,6 +25,7 @@ const plans = [
'이메일 발송',
],
highlight: false,
+ productId: 'lotto_basic',
},
{
name: '프리미엄 플랜',
@@ -38,6 +40,7 @@ const plans = [
'이메일 + 텔레그램 알림',
],
highlight: true,
+ productId: 'lotto_premium',
},
{
name: '연간 플랜',
@@ -51,6 +54,7 @@ const plans = [
'2개월 무료 혜택',
],
highlight: false,
+ productId: 'lotto_annual',
},
];
@@ -210,14 +214,14 @@ export default function LottoPage() {
))}
-
+
))}
diff --git a/app/services/stock/page.tsx b/app/services/stock/page.tsx
index f837b65..a3d6bf2 100644
--- a/app/services/stock/page.tsx
+++ b/app/services/stock/page.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '../../components/ContactModal';
+import PaymentButton from '../../components/PaymentButton';
const CHECKLIST = [
'사용 중인 증권사 확인 (키움증권 / 한국투자증권 권장)',
@@ -29,6 +30,7 @@ const plans = [
desc: '1개 종목 자동 매매',
features: ['1개 종목 모니터링', '텔레그램 매매 알림', '기본 기술적 분석 전략', '손절/익절 자동 설정', '월간 손익 리포트'],
highlight: false,
+ installProductId: 'stock_starter_install',
},
{
name: '프로',
@@ -37,6 +39,7 @@ const plans = [
desc: '최대 5개 종목 + 전략 커스터마이징',
features: ['최대 5개 종목 동시 운영', '전략 파라미터 커스터마이징', '다중 기술적 지표 조합', '실시간 포트폴리오 현황', '주간 성과 분석 리포트', '1개월 무상 기술 지원'],
highlight: true,
+ installProductId: 'stock_pro_install',
},
{
name: '엔터프라이즈',
@@ -45,6 +48,7 @@ const plans = [
desc: '무제한 종목 + 맞춤 전략 개발',
features: ['종목 제한 없음', '완전 맞춤 전략 개발', '백테스팅 리포트 제공', '전용 서버 구성 가능', '24시간 모니터링', '전담 유지보수 계약'],
highlight: false,
+ installProductId: null,
},
];
@@ -206,14 +210,23 @@ export default function StockPage() {
))}
-
+ {plan.installProductId ? (
+
+ 설치 결제하기
+
+ ) : (
+
+ )}
))}
diff --git a/lib/ai-interpretation.ts b/lib/ai-interpretation.ts
new file mode 100644
index 0000000..85ffcc8
--- /dev/null
+++ b/lib/ai-interpretation.ts
@@ -0,0 +1,386 @@
+
+import {
+ SajuData, FIVE_ELEMENTS, HEAVENLY_STEMS,
+ getHiddenStems, getAllHiddenStems,
+ analyzeBranchInteractions, calculateShinsal, calculateGongmang,
+ getYearGanzi, FIVE_ELEMENTS_KR, EARTHLY_BRANCHES_KR, EARTHLY_BRANCHES,
+ BranchInteraction, Shinsal,
+} from './saju-calculator';
+import { DaeunPillar } from './daeun-calculator';
+
+// ============================================================
+// 오행 밸런스 정밀 분석 (가중치 적용)
+// ============================================================
+
+export interface ElementBalance {
+ 木: number;
+ 火: number;
+ 土: number;
+ 金: number;
+ 水: number;
+}
+
+/**
+ * 가중치 적용 오행 점수 계산
+ * - 천간: 1.0
+ * - 지지 본기(정기): 1.0
+ * - 지장간 중기: 0.5
+ * - 지장간 여기: 0.3
+ */
+export function calculateDetailedElementBalance(saju: SajuData): ElementBalance {
+ const balance: ElementBalance = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 };
+
+ // 천간 오행 (각 1.0)
+ const stems = [saju.year.stem, saju.month.stem, saju.day.stem];
+ if (saju.hour) stems.push(saju.hour.stem);
+
+ for (const stem of stems) {
+ const elem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
+ if (elem) balance[elem] += 1.0;
+ }
+
+ // 지지 지장간 (본기 1.0, 중기 0.5, 여기 0.3)
+ const branches = [saju.year.branch, saju.month.branch, saju.day.branch];
+ if (saju.hour) branches.push(saju.hour.branch);
+
+ const weights = [1.0, 0.5, 0.3];
+ for (const branch of branches) {
+ const hidden = getHiddenStems(branch);
+ for (let i = 0; i < hidden.length; i++) {
+ const elem = FIVE_ELEMENTS[hidden[i] as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
+ if (elem) balance[elem] += weights[i] || 0.3;
+ }
+ }
+
+ // 소수점 둘째 자리로 반올림
+ for (const key of Object.keys(balance) as (keyof ElementBalance)[]) {
+ balance[key] = Math.round(balance[key] * 100) / 100;
+ }
+
+ return balance;
+}
+
+/**
+ * 오행 비율(%) 계산
+ */
+export function calculateElementScore(saju: SajuData): { [key: string]: number } {
+ const balance = calculateDetailedElementBalance(saju);
+ const total = Object.values(balance).reduce((a, b) => a + b, 0);
+
+ const scores: { [key: string]: number } = {};
+ for (const [element, value] of Object.entries(balance)) {
+ scores[element] = total > 0 ? Math.round((value / total) * 100) : 0;
+ }
+
+ return scores;
+}
+
+// ============================================================
+// 신강/신약 자동 판단
+// ============================================================
+
+export interface DayMasterStrength {
+ result: '신강' | '신약' | '중화';
+ score: number;
+ reasons: string[];
+}
+
+const PRODUCE_MAP: { [key: string]: string } = {
+ '木': '火', '火': '土', '土': '金', '金': '水', '水': '木',
+};
+
+function getProducingElement(elem: string): string {
+ for (const [k, v] of Object.entries(PRODUCE_MAP)) {
+ if (v === elem) return k;
+ }
+ return '';
+}
+
+/**
+ * 신강/신약 판단
+ */
+export function analyzeDayMasterStrength(saju: SajuData): DayMasterStrength {
+ const dayStem = saju.dayStem;
+ const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
+ const producingElement = getProducingElement(dayElement);
+ const reasons: string[] = [];
+ let score = 0;
+
+ // 1. 월령 득령 확인
+ const monthBranch = saju.month.branch;
+ const monthHidden = getHiddenStems(monthBranch);
+ const monthMainElement = FIVE_ELEMENTS[monthHidden[0] as keyof typeof FIVE_ELEMENTS];
+
+ if (monthMainElement === dayElement) {
+ score += 3;
+ reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간과 같은 ${FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR]}으로 강한 힘을 받음`);
+ } else if (monthMainElement === producingElement) {
+ score += 2;
+ reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 생하는 ${FIVE_ELEMENTS_KR[producingElement as keyof typeof FIVE_ELEMENTS_KR]}으로 힘을 받음`);
+ } else {
+ score -= 2;
+ reasons.push(`월령 실령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 돕지 않음`);
+ }
+
+ // 2. 통근 확인
+ const allBranches = [saju.year.branch, saju.month.branch, saju.day.branch];
+ if (saju.hour) allBranches.push(saju.hour.branch);
+
+ let rootCount = 0;
+ for (const branch of allBranches) {
+ const hidden = getHiddenStems(branch);
+ for (const h of hidden) {
+ const hElem = FIVE_ELEMENTS[h as keyof typeof FIVE_ELEMENTS];
+ if (hElem === dayElement || hElem === producingElement) {
+ rootCount++;
+ break;
+ }
+ }
+ }
+
+ if (rootCount >= 3) {
+ score += 2;
+ reasons.push(`통근 강함: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
+ } else if (rootCount >= 2) {
+ score += 1;
+ reasons.push(`통근 보통: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
+ } else {
+ score -= 1;
+ reasons.push(`통근 약함: ${rootCount}개 지지에서만 일간의 뿌리를 찾음`);
+ }
+
+ // 3. 투출 확인
+ const allStems = [saju.year.stem, saju.month.stem];
+ if (saju.hour) allStems.push(saju.hour.stem);
+
+ let helpingStemCount = 0;
+ for (const stem of allStems) {
+ const stemElem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS];
+ if (stemElem === dayElement || stemElem === producingElement) {
+ helpingStemCount++;
+ }
+ }
+
+ if (helpingStemCount >= 2) {
+ score += 2;
+ reasons.push(`투출 강함: 천간에 비겁/인성이 ${helpingStemCount}개 있어 일간을 도움`);
+ } else if (helpingStemCount === 1) {
+ score += 1;
+ reasons.push(`투출 보통: 천간에 비겁/인성이 1개 있음`);
+ } else {
+ score -= 1;
+ reasons.push(`투출 없음: 천간에 일간을 돕는 비겁/인성이 없음`);
+ }
+
+ // 4. 오행 비율 기반 조력 분석
+ const balance = calculateDetailedElementBalance(saju);
+ const helpingScore = balance[dayElement as keyof ElementBalance] + balance[producingElement as keyof ElementBalance];
+ const drainingScore = Object.entries(balance)
+ .filter(([k]) => k !== dayElement && k !== producingElement)
+ .reduce((sum, [, v]) => sum + v, 0);
+
+ if (helpingScore > drainingScore * 1.3) {
+ score += 1;
+ reasons.push(`오행 비율: 비겁+인성(${helpingScore.toFixed(1)}) > 식상+재관(${drainingScore.toFixed(1)}) → 일간 세력 우세`);
+ } else if (drainingScore > helpingScore * 1.3) {
+ score -= 1;
+ reasons.push(`오행 비율: 식상+재관(${drainingScore.toFixed(1)}) > 비겁+인성(${helpingScore.toFixed(1)}) → 일간 세력 열세`);
+ }
+
+ let result: '신강' | '신약' | '중화';
+ if (score >= 3) result = '신강';
+ else if (score <= -2) result = '신약';
+ else result = '중화';
+
+ return { result, score, reasons };
+}
+
+// ============================================================
+// 용신 (用神) 추정
+// ============================================================
+
+export interface YongShinResult {
+ yongShin: string;
+ yongShinKr: string;
+ heeShin: string;
+ heeShinKr: string;
+ giShin: string;
+ giShinKr: string;
+ explanation: string;
+}
+
+const OVERCOME_MAP: { [key: string]: string } = {
+ '木': '土', '火': '金', '土': '水', '金': '木', '水': '火',
+};
+
+function getOvercomingMe(elem: string): string {
+ for (const [k, v] of Object.entries(OVERCOME_MAP)) {
+ if (v === elem) return k;
+ }
+ return '';
+}
+
+export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): YongShinResult {
+ const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
+ const balance = calculateDetailedElementBalance(saju);
+
+ const producingMe = getProducingElement(dayElement); // 인성
+ const myProduct = PRODUCE_MAP[dayElement]; // 식상
+ const myOvercome = OVERCOME_MAP[dayElement]; // 재성
+ const overcomeMe = getOvercomingMe(dayElement); // 관살
+
+ const kr = (e: string) => FIVE_ELEMENTS_KR[e as keyof typeof FIVE_ELEMENTS_KR] || e;
+
+ if (strength.result === '신강') {
+ const candidates = [
+ { elem: myProduct, score: balance[myProduct as keyof ElementBalance], name: '식상' },
+ { elem: myOvercome, score: balance[myOvercome as keyof ElementBalance], name: '재성' },
+ { elem: overcomeMe, score: balance[overcomeMe as keyof ElementBalance], name: '관살' },
+ ];
+ candidates.sort((a, b) => a.score - b.score);
+ const yong = candidates[0];
+ const hee = candidates[1];
+
+ return {
+ yongShin: yong.elem, yongShinKr: kr(yong.elem),
+ heeShin: hee.elem, heeShinKr: kr(hee.elem),
+ giShin: dayElement, giShinKr: kr(dayElement),
+ explanation: `신강한 사주로 일간의 힘이 넘치므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
+ };
+ } else if (strength.result === '신약') {
+ const candidates = [
+ { elem: producingMe, score: balance[producingMe as keyof ElementBalance], name: '인성' },
+ { elem: dayElement, score: balance[dayElement as keyof ElementBalance], name: '비겁' },
+ ];
+ candidates.sort((a, b) => a.score - b.score);
+ const yong = candidates[0];
+ const hee = candidates[1];
+
+ return {
+ yongShin: yong.elem, yongShinKr: kr(yong.elem),
+ heeShin: hee.elem, heeShinKr: kr(hee.elem),
+ giShin: overcomeMe, giShinKr: kr(overcomeMe),
+ explanation: `신약한 사주로 일간의 힘이 부족하므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
+ };
+ } else {
+ const entries = Object.entries(balance) as [string, number][];
+ entries.sort((a, b) => a[1] - b[1]);
+ const yong = entries[0];
+ const hee = entries[1];
+ const gi = entries[entries.length - 1];
+
+ return {
+ yongShin: yong[0], yongShinKr: kr(yong[0]),
+ heeShin: hee[0], heeShinKr: kr(hee[0]),
+ giShin: gi[0], giShinKr: kr(gi[0]),
+ explanation: `중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. 가장 부족한 ${kr(yong[0])}(${yong[0]}) 기운을 보충하면 더욱 좋아집니다.`,
+ };
+ }
+}
+
+// ============================================================
+// 세운 (歲運) 계산
+// ============================================================
+
+export interface SeunInfo {
+ stem: string;
+ branch: string;
+ stemKr: string;
+ branchKr: string;
+ element: string;
+ elementKr: string;
+ year: number;
+ interactions: BranchInteraction[];
+}
+
+export function calculateSeun(year: number, saju: SajuData): SeunInfo {
+ const ganzi = getYearGanzi(year);
+ const element = FIVE_ELEMENTS[ganzi.stem as keyof typeof FIVE_ELEMENTS];
+
+ const seunBranch = ganzi.branch;
+ const seunBranchKr = ganzi.branchKr;
+
+ const interactions: BranchInteraction[] = [];
+ const pillarBranches = [
+ { branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
+ { branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
+ { branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
+ ];
+ if (saju.hour) {
+ pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
+ }
+
+ const CHUNG: [string, string][] = [
+ ['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
+ ];
+ for (const [a, b] of CHUNG) {
+ for (const pb of pillarBranches) {
+ if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
+ interactions.push({
+ type: '충(沖)', branches: [seunBranch, pb.branch],
+ branchesKr: [seunBranchKr, pb.branchKr],
+ pillars: ['세운', pb.pillar],
+ description: `세운 ${seunBranchKr}와 ${pb.pillar} ${pb.branchKr}가 충 → 해당 영역에 변동과 변화가 예상됨.`,
+ });
+ }
+ }
+ }
+
+ const YUKAP: [string, string, string][] = [
+ ['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
+ ['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
+ ];
+ for (const [a, b, elem] of YUKAP) {
+ for (const pb of pillarBranches) {
+ if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
+ interactions.push({
+ type: '합(合)', branches: [seunBranch, pb.branch],
+ branchesKr: [seunBranchKr, pb.branchKr],
+ pillars: ['세운', pb.pillar],
+ description: `세운 ${seunBranchKr}와 ${pb.pillar} ${pb.branchKr}가 합 → 해당 영역에 조화와 좋은 인연이 기대됨.`,
+ resultElement: elem,
+ });
+ }
+ }
+ }
+
+ return {
+ stem: ganzi.stem, branch: ganzi.branch,
+ stemKr: ganzi.stemKr, branchKr: ganzi.branchKr,
+ element, elementKr: FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR],
+ year, interactions,
+ };
+}
+
+// ============================================================
+// 종합 분석 데이터 구조체
+// ============================================================
+
+export interface SajuAnalysis {
+ elementBalance: ElementBalance;
+ elementScores: { [key: string]: number };
+ dayMasterStrength: DayMasterStrength;
+ yongShin: YongShinResult;
+ branchInteractions: BranchInteraction[];
+ shinsal: Shinsal[];
+ gongmang: { branches: string[]; branchesKr: string[]; description: string };
+ seun: SeunInfo;
+ hiddenStems: { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[];
+}
+
+export function performFullAnalysis(saju: SajuData, currentYear: number = new Date().getFullYear()): SajuAnalysis {
+ const elementBalance = calculateDetailedElementBalance(saju);
+ const elementScores = calculateElementScore(saju);
+ const dayMasterStrength = analyzeDayMasterStrength(saju);
+ const yongShin = estimateYongShin(saju, dayMasterStrength);
+ const branchInteractions = analyzeBranchInteractions(saju);
+ const shinsal = calculateShinsal(saju);
+ const gongmang = calculateGongmang(saju.dayStem, saju.day.branch);
+ const seun = calculateSeun(currentYear, saju);
+ const hiddenStems = getAllHiddenStems(saju);
+
+ return {
+ elementBalance, elementScores, dayMasterStrength, yongShin,
+ branchInteractions, shinsal, gongmang, seun, hiddenStems,
+ };
+}
diff --git a/lib/daeun-calculator.ts b/lib/daeun-calculator.ts
new file mode 100644
index 0000000..8a6d94d
--- /dev/null
+++ b/lib/daeun-calculator.ts
@@ -0,0 +1,188 @@
+import { HEAVENLY_STEMS, EARTHLY_BRANCHES, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES_KR } from './saju-calculator';
+
+/**
+ * 대운 (大運) 정보
+ */
+export interface DaeunPillar {
+ age: number; // 시작 나이
+ startYear: number; // 시작 년도
+ endYear: number; // 끝 년도
+ stem: string; // 천간
+ branch: string; // 지지
+ stemKr: string; // 천간 한글
+ branchKr: string; // 지지 한글
+}
+
+/**
+ * 대운 시작 나이 정밀 계산
+ * @param birthYear 생년
+ * @param birthMonth 생월
+ * @param birthDay 생일
+ * @param gender 성별
+ * @param isYangYear 양년 여부
+ * @returns 대운 시작 나이
+ */
+function calculateDaeunStartAge(
+ birthYear: number,
+ birthMonth: number,
+ birthDay: number,
+ gender: 'male' | 'female',
+ isYangYear: boolean
+): number {
+ const { getDaysToNextSolarTerm, getCurrentSolarTerm, getSolarTermDate } = require('./solar-terms');
+
+ // 양남음녀는 순행 (다음 절기까지), 음남양녀는 역행 (이전 절기부터)
+ let days: number;
+
+ if ((gender === 'male' && isYangYear) || (gender === 'female' && !isYangYear)) {
+ // 순행: 생일부터 다음 절기까지의 일수
+ days = getDaysToNextSolarTerm(birthYear, birthMonth, birthDay);
+ } else {
+ // 역행: 이전 절기부터 생일까지의 일수
+ const currentTerm = getCurrentSolarTerm(birthYear, birthMonth, birthDay);
+ const termDate = getSolarTermDate(birthYear, currentTerm);
+
+ let termYear = termDate.year;
+ let termMonth = termDate.month;
+
+ // 대한, 소한 처리
+ if (currentTerm >= 22 && birthMonth >= 2) {
+ termYear = birthYear;
+ } else if (currentTerm >= 22) {
+ termYear = birthYear - 1;
+ }
+
+ const termDateObj = new Date(termYear, termMonth - 1, termDate.day);
+ const birthDateObj = new Date(birthYear, birthMonth - 1, birthDay);
+
+ const diffTime = birthDateObj.getTime() - termDateObj.getTime();
+ days = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+ }
+
+ // 3일 = 1세 (대운수)
+ // 정확히는 3일당 1세이지만, 일수를 3으로 나눈 몫
+ const startAge = Math.floor(days / 3);
+
+ // 최소 1세, 최대 10세로 제한
+ return Math.max(1, Math.min(10, startAge));
+}
+
+/**
+ * 대운 계산
+ * @param birthYear 생년
+ * @param birthMonth 생월
+ * @param birthDay 생일
+ * @param gender 성별
+ * @param monthStem 월주 천간 인덱스
+ * @param monthBranch 월주 지지 인덱스
+ * @returns 대운 배열 (10년 단위)
+ */
+export function calculateDaeun(
+ birthYear: number,
+ birthMonth: number,
+ birthDay: number,
+ gender: 'male' | 'female',
+ monthStem: string,
+ monthBranch: string
+): DaeunPillar[] {
+ const monthStemIndex = HEAVENLY_STEMS.indexOf(monthStem as any);
+ const monthBranchIndex = EARTHLY_BRANCHES.indexOf(monthBranch as any);
+
+ if (monthStemIndex === -1 || monthBranchIndex === -1) {
+ return [];
+ }
+
+ // 양남음녀(陽男陰女)는 순행, 음남양녀(陰男陽女)는 역행
+ const yearStemIndex = (birthYear - 1900 + 6) % 10;
+ const isYangYear = yearStemIndex % 2 === 0; // 양년
+
+ let isForward: boolean;
+ if (gender === 'male') {
+ isForward = isYangYear; // 양남: 순행, 음남: 역행
+ } else {
+ isForward = !isYangYear; // 양녀: 역행, 음녀: 순행
+ }
+
+ // 대운 시작 나이 정밀 계산 (절기 기준)
+ const startAge = calculateDaeunStartAge(birthYear, birthMonth, birthDay, gender, isYangYear);
+
+ const daeunList: DaeunPillar[] = [];
+
+ for (let i = 0; i < 8; i++) {
+ const age = startAge + (i * 10);
+ const startYear = birthYear + age;
+ const endYear = startYear + 9;
+
+ let stemIndex: number;
+ let branchIndex: number;
+
+ if (isForward) {
+ // 순행: 월주에서 증가
+ stemIndex = (monthStemIndex + i + 1) % 10;
+ branchIndex = (monthBranchIndex + i + 1) % 12;
+ } else {
+ // 역행: 월주에서 감소
+ stemIndex = (monthStemIndex - i - 1 + 100) % 10;
+ branchIndex = (monthBranchIndex - i - 1 + 120) % 12;
+ }
+
+ daeunList.push({
+ age,
+ startYear,
+ endYear,
+ stem: HEAVENLY_STEMS[stemIndex],
+ branch: EARTHLY_BRANCHES[branchIndex],
+ stemKr: HEAVENLY_STEMS_KR[stemIndex],
+ branchKr: EARTHLY_BRANCHES_KR[branchIndex]
+ });
+ }
+
+ return daeunList;
+}
+
+/**
+ * 현재 대운 찾기
+ * @param daeunList 대운 목록
+ * @param currentYear 현재 년도
+ */
+export function getCurrentDaeun(daeunList: DaeunPillar[], currentYear: number): DaeunPillar | null {
+ for (const daeun of daeunList) {
+ if (currentYear >= daeun.startYear && currentYear <= daeun.endYear) {
+ return daeun;
+ }
+ }
+ return null;
+}
+
+/**
+ * 대운 해석
+ * @param daeun 대운 정보
+ * @param dayStem 일간
+ */
+export function getDaeunDescription(daeun: DaeunPillar, dayStem: string): string {
+ const age = daeun.age;
+ const ganzi = `${daeun.stem}${daeun.branch}`;
+
+ let description = `${age}세부터 ${age + 9}세까지의 10년은 ${daeun.stemKr}${daeun.branchKr}(${ganzi}) 대운입니다. `;
+
+ // 대운 천간과 일간의 관계에 따른 기본 해석
+ const stemIndex = HEAVENLY_STEMS.indexOf(daeun.stem as any);
+
+ if (age < 20) {
+ description += '청소년기로 학업과 기초를 다지는 시기입니다. ';
+ } else if (age < 40) {
+ description += '성장과 발전의 시기로 사회활동이 왕성한 때입니다. ';
+ } else if (age < 60) {
+ description += '안정과 성숙의 시기로 경험이 쌓이는 때입니다. ';
+ } else {
+ description += '원숙한 시기로 인생의 지혜를 나누는 때입니다. ';
+ }
+
+ if (stemIndex % 2 === 0) {
+ description += '적극적이고 외향적인 활동이 유리합니다.';
+ } else {
+ description += '차분하고 내실을 다지는 것이 좋습니다.';
+ }
+
+ return description;
+}
diff --git a/lib/lunar-utils.ts b/lib/lunar-utils.ts
new file mode 100644
index 0000000..ee9ce51
--- /dev/null
+++ b/lib/lunar-utils.ts
@@ -0,0 +1,97 @@
+/**
+ * 음력-양력 변환 유틸리티
+ */
+
+interface LunarDate {
+ year: number;
+ month: number;
+ day: number;
+ isLeap: boolean;
+}
+
+interface SolarDate {
+ year: number;
+ month: number;
+ day: number;
+}
+
+/**
+ * 음력을 양력으로 변환
+ * @param lunarYear 음력 년
+ * @param lunarMonth 음력 월
+ * @param lunarDay 음력 일
+ * @param isLeapMonth 윤달 여부
+ */
+export function lunarToSolar(
+ lunarYear: number,
+ lunarMonth: number,
+ lunarDay: number,
+ isLeapMonth: boolean = false
+): SolarDate {
+ try {
+ const lunar = require('lunar-calendar');
+ const result = lunar.lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeapMonth);
+
+ return {
+ year: result.year,
+ month: result.month,
+ day: result.day
+ };
+ } catch (error) {
+ console.error('음력 변환 오류:', error);
+ // 변환 실패시 입력값 그대로 반환
+ return {
+ year: lunarYear,
+ month: lunarMonth,
+ day: lunarDay
+ };
+ }
+}
+
+/**
+ * 양력을 음력으로 변환
+ * @param solarYear 양력 년
+ * @param solarMonth 양력 월
+ * @param solarDay 양력 일
+ */
+export function solarToLunar(
+ solarYear: number,
+ solarMonth: number,
+ solarDay: number
+): LunarDate {
+ try {
+ const lunar = require('lunar-calendar');
+ const result = lunar.solarToLunar(solarYear, solarMonth, solarDay);
+
+ return {
+ year: result.year,
+ month: result.month,
+ day: result.day,
+ isLeap: result.isLeap || false
+ };
+ } catch (error) {
+ console.error('양력 변환 오류:', error);
+ // 변환 실패시 입력값 그대로 반환
+ return {
+ year: solarYear,
+ month: solarMonth,
+ day: solarDay,
+ isLeap: false
+ };
+ }
+}
+
+/**
+ * 음력 날짜를 문자열로 변환
+ */
+export function formatLunarDate(lunar: LunarDate): string {
+ const leapText = lunar.isLeap ? '윤' : '';
+ return `음력 ${lunar.year}년 ${leapText}${lunar.month}월 ${lunar.day}일`;
+}
+
+/**
+ * 양력 날짜를 문자열로 변환
+ */
+export function formatSolarDate(solar: SolarDate): string {
+ return `양력 ${solar.year}년 ${solar.month}월 ${solar.day}일`;
+}
diff --git a/lib/products.ts b/lib/products.ts
new file mode 100644
index 0000000..251b3c9
--- /dev/null
+++ b/lib/products.ts
@@ -0,0 +1,66 @@
+export interface Product {
+ id: string;
+ name: string;
+ price: number;
+ type: 'one_time' | 'monthly' | 'annual';
+ description: string;
+}
+
+export const PRODUCTS: Record = {
+ lotto_basic: {
+ id: 'lotto_basic',
+ name: '로또 기본 플랜',
+ price: 4900,
+ type: 'monthly',
+ description: '매주 5개 번호 조합 이메일 제공',
+ },
+ lotto_premium: {
+ id: 'lotto_premium',
+ name: '로또 프리미엄 플랜',
+ price: 9900,
+ type: 'monthly',
+ description: '매주 3회 번호 + 텔레그램 알림',
+ },
+ lotto_annual: {
+ id: 'lotto_annual',
+ name: '로또 연간 플랜',
+ price: 89900,
+ type: 'annual',
+ description: '프리미엄 12개월 (2개월 무료)',
+ },
+ stock_starter_install: {
+ id: 'stock_starter_install',
+ name: '주식 스타터 설치',
+ price: 99000,
+ type: 'one_time',
+ description: '1개 종목 자동 매매 설치',
+ },
+ stock_pro_install: {
+ id: 'stock_pro_install',
+ name: '주식 프로 설치',
+ price: 199000,
+ type: 'one_time',
+ description: '5개 종목 + 전략 커스터마이징 설치',
+ },
+ stock_starter_monthly: {
+ id: 'stock_starter_monthly',
+ name: '주식 스타터 월 유지비',
+ price: 29000,
+ type: 'monthly',
+ description: '스타터 월 유지보수 비용',
+ },
+ stock_pro_monthly: {
+ id: 'stock_pro_monthly',
+ name: '주식 프로 월 유지비',
+ price: 49000,
+ type: 'monthly',
+ description: '프로 월 유지보수 비용',
+ },
+ saju_detail: {
+ id: 'saju_detail',
+ name: 'AI 사주 상세 리포트',
+ price: 4900,
+ type: 'one_time',
+ description: 'AI 12가지 항목 상세 해석',
+ },
+};
diff --git a/lib/saju-ai-prompt.ts b/lib/saju-ai-prompt.ts
new file mode 100644
index 0000000..bd4236e
--- /dev/null
+++ b/lib/saju-ai-prompt.ts
@@ -0,0 +1,223 @@
+
+import { SajuData, FIVE_ELEMENTS, FIVE_ELEMENTS_KR, HEAVENLY_STEMS_KR } from './saju-calculator';
+import { DaeunPillar } from './daeun-calculator';
+import { SajuAnalysis } from './ai-interpretation';
+
+export function createSajuPrompt(
+ saju: SajuData,
+ currentDaeun: DaeunPillar | null,
+ gender: 'male' | 'female',
+ analysis: SajuAnalysis,
+ daeunList: DaeunPillar[] = []
+): string {
+ const genderStr = gender === 'male' ? '남성' : '여성';
+ const birthDate = `${saju.birthDate.year}년 ${saju.birthDate.month}월 ${saju.birthDate.day}일 ${saju.birthDate.hour ? saju.birthDate.hour + '시' : '시간 모름'}`;
+ const dayStemKr = saju.day.stemKr;
+ const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
+ const dayElementKr = FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR];
+
+ // ── 사주 원국 ──
+ const pillars = [
+ `년주: ${saju.year.stem}${saju.year.branch} (${saju.year.stemKr}${saju.year.branchKr}) | 천간십성: ${saju.year.tenGod} | 십이운성: ${saju.year.fortune}`,
+ `월주: ${saju.month.stem}${saju.month.branch} (${saju.month.stemKr}${saju.month.branchKr}) | 천간십성: ${saju.month.tenGod} | 십이운성: ${saju.month.fortune}`,
+ `일주: ${saju.day.stem}${saju.day.branch} (${saju.day.stemKr}${saju.day.branchKr}) | 일간(日干) | 십이운성: ${saju.day.fortune}`,
+ saju.hour
+ ? `시주: ${saju.hour.stem}${saju.hour.branch} (${saju.hour.stemKr}${saju.hour.branchKr}) | 천간십성: ${saju.hour.tenGod} | 십이운성: ${saju.hour.fortune}`
+ : '시주: 정보 없음',
+ ].join('\n');
+
+ // ── 지장간 ──
+ const hiddenStemsStr = analysis.hiddenStems.map(h => {
+ const stemsDetail = h.stems.map(s => `${s.stemKr}(${s.stem}, ${FIVE_ELEMENTS_KR[s.element as keyof typeof FIVE_ELEMENTS_KR]}, ${s.role})`).join(', ');
+ return `${h.pillar} ${h.branchKr}(${h.branch}): [${stemsDetail}]`;
+ }).join('\n');
+
+ // ── 오행 분석 ──
+ const eb = analysis.elementBalance;
+ const es = analysis.elementScores;
+ const elementStr = Object.entries(eb).map(([k, v]) => {
+ return `${FIVE_ELEMENTS_KR[k as keyof typeof FIVE_ELEMENTS_KR]}(${k}): ${v}점 (${es[k]}%)`;
+ }).join(' | ');
+
+ // ── 신강/신약 ──
+ const strength = analysis.dayMasterStrength;
+ const strengthStr = `판정: ${strength.result} (점수: ${strength.score})\n근거:\n${strength.reasons.map(r => `- ${r}`).join('\n')}`;
+
+ // ── 용신/희신/기신 ──
+ const ys = analysis.yongShin;
+ const yongShinStr = `용신: ${ys.yongShinKr}(${ys.yongShin}) | 희신: ${ys.heeShinKr}(${ys.heeShin}) | 기신: ${ys.giShinKr}(${ys.giShin})\n설명: ${ys.explanation}`;
+
+ // ── 지지 상호작용 ──
+ const interactionsStr = analysis.branchInteractions.length > 0
+ ? analysis.branchInteractions.map(i => `- ${i.type}: ${i.branchesKr.join('')} (${i.pillars.join('↔')}) → ${i.description}`).join('\n')
+ : '- 특별한 합/충/형/파/해 없음';
+
+ // ── 신살 ──
+ const shinsalStr = analysis.shinsal.length > 0
+ ? analysis.shinsal.map(s => `- ${s.name}(${s.nameHanja}): ${s.pillar} ${s.branchKr}(${s.branch}) → ${s.description}`).join('\n')
+ : '- 특별한 신살 없음';
+
+ // ── 공망 ──
+ const gongmangStr = analysis.gongmang.description;
+
+ // ── 세운 ──
+ const seun = analysis.seun;
+ const seunStr = `${seun.year}년 ${seun.stemKr}${seun.branchKr}(${seun.stem}${seun.branch})년 | 오행: ${seun.elementKr}(${seun.element})`;
+ const seunInteractions = seun.interactions.length > 0
+ ? seun.interactions.map(i => `- ${i.type}: ${i.description}`).join('\n')
+ : '- 세운과 원국 사이에 특별한 충/합 없음';
+
+ // ── 대운 ──
+ const daeunInfo = currentDaeun
+ ? `현재 대운: ${currentDaeun.stemKr}${currentDaeun.branchKr}(${currentDaeun.stem}${currentDaeun.branch}) 대운 | ${currentDaeun.age}세~${currentDaeun.age + 9}세 (${currentDaeun.startYear}~${currentDaeun.endYear}년)`
+ : '현재 대운 정보 없음';
+
+ const allDaeunStr = daeunList.length > 0
+ ? daeunList.map(d => `${d.stemKr}${d.branchKr}(${d.age}세~${d.age + 9}세, ${d.startYear}~${d.endYear}년)`).join(' → ')
+ : '';
+
+ const systemPrompt = `당신은 따뜻하고 유머러스한 사주 상담사예요. 마치 오랜 친구처럼 편하게, 하지만 놀라울 정도로 정확하게 사주를 읽어주는 사람이에요. 딱딱한 전문 용어 대신 비유와 이야기로 풀어내는 게 당신의 스타일이에요.
+
+[핵심 원칙 - 반드시 지켜주세요]
+- 아래 제공된 계산 데이터를 바탕으로 해석하되, 전문 용어는 최소화하고 비유와 스토리텔링으로 풀어주세요.
+- "~요" 체의 친근한 말투를 사용하세요. (예: "~이에요", "~거든요", "~잖아요", "~인 거죠")
+- 각 섹션 제목은 창의적인 비유나 은유를 사용한 감성적 제목으로 만드세요. (예: "얼음 속에 숨겨진 불꽃", "당신 안의 숨은 보석")
+- 사주 데이터에 근거하되, "당신은 마치 ~같은 사람이에요"처럼 생생한 비유로 설명하세요.
+- 때로는 따끔한 조언도 섞어주세요. 친구가 해주는 솔직한 충고처럼요. (예: "솔직히 말하면... 그거 완벽주의 아니고 그냥 겁이 많은 거예요 😅")
+- 각 항목 최소 5~8문장으로 깊이 있게, 하지만 술술 읽히게 작성하세요.
+- 이 사람만을 위한 개인화된 분석이어야 해요. 일반론 절대 금지!
+- 중간중간 공감 포인트를 넣어주세요. (예: "혹시 이런 경험 있지 않나요?", "맞죠?")
+- 마지막에 진심 어린 응원 한마디를 꼭 넣어주세요.
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+[사용자 정보]
+- 성별: ${genderStr}
+- 생년월일시: ${birthDate}
+- 일간: ${dayStemKr}(${saju.dayStem}) → ${dayElementKr}(${dayElement})
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+[사주 원국]
+${pillars}
+
+[지장간]
+${hiddenStemsStr}
+
+[오행 점수 (가중치 적용)]
+${elementStr}
+총점: ${Object.values(eb).reduce((a, b) => a + b, 0).toFixed(1)}점
+
+[신강/신약]
+${strengthStr}
+
+[용신/희신/기신]
+${yongShinStr}
+
+[지지 상호작용]
+${interactionsStr}
+
+[신살]
+${shinsalStr}
+
+[공망]
+${gongmangStr}
+
+[대운]
+${daeunInfo}
+전체 흐름: ${allDaeunStr}
+
+[세운 - 올해]
+${seunStr}
+${seunInteractions}
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+[분석 요구사항 - 12개 항목]
+
+위 데이터를 바탕으로 아래 12개 항목을 작성하세요.
+각 항목은 반드시 "## " 로 시작하는 헤더를 사용하세요.
+헤더 제목은 번호 + 창의적인 비유/은유 제목으로 만드세요. (아래는 예시일 뿐, 사주 내용에 맞게 자유롭게 창작하세요)
+
+## 1. [타고난 기질 - 창의적 제목]
+예시 제목: "차가운 호수 아래 숨겨진 용의 심장" / "봄바람처럼 자유로운 영혼"
+- ${dayStemKr}${saju.day.branchKr}일주의 핵심 성격을 비유로 풀어주세요
+- "당신은 마치 ~같은 사람이에요" 패턴 활용
+- 겉으로 보이는 모습 vs 진짜 내면을 대비시켜 흥미롭게
+- 강점은 확 칭찬하고, 약점은 "솔직히 말하면..." 패턴으로 따끔하지만 사랑스럽게
+
+## 2. [오행 밸런스 & 개운법 - 창의적 제목]
+예시 제목: "당신에게 부족한 한 조각, 그걸 채우는 법" / "운을 끌어당기는 나만의 비밀 무기"
+- 오행 데이터를 인용하되, "당신의 에너지 밸런스를 보면..." 식으로 쉽게
+- 용신(${ys.yongShinKr}) 기운을 강화하는 실생활 팁: 색상, 방향, 숫자, 음식, 행동
+- 기신(${ys.giShinKr}) 기운 피하는 법도 구체적으로
+- "오늘부터 당장 ~해보세요!" 같은 실천 가능한 조언
+
+## 3. [지지 상호작용 - 창의적 제목]
+예시 제목: "당신 안에서 벌어지는 보이지 않는 전쟁" / "운명이 엮어준 특별한 인연의 실타래"
+- 합/충/형 데이터를 바탕으로 실생활 영향을 이야기로 풀어주세요
+- 어려운 용어 대신 "쉽게 말하면..." 패턴 활용
+
+## 4. [신살의 영향 - 창의적 제목]
+예시 제목: "당신이 타고난 숨겨진 초능력" / "조심해야 할 함정, 그리고 날개"
+- 각 신살을 흥미로운 비유로 설명 (역마살 → "여행자의 별", 도화살 → "매력의 별" 등)
+- 긍정 신살은 신나게, 주의 신살은 걱정 말라는 톤으로
+
+## 5. [재물운 - 창의적 제목]
+예시 제목: "돈이 당신을 찾아오는 방식" / "통장이 웃는 시기, 우는 시기"
+- 편재/정재 위치와 강도를 쉬운 비유로
+- 돈 버는 스타일 (한방 vs 꾸준히 vs 투자형 등)
+- 주의할 시기와 기회의 시기를 구체적으로
+
+## 6. [직업 적성 - 창의적 제목]
+예시 제목: "당신이 빛나는 무대는 따로 있어요" / "타고난 프로의 DNA"
+- 적합한 분야를 구체적으로 추천 (추상적 말고 직업명까지)
+- 조직형 vs 프리랜서/사업형 판단
+- ${genderStr}의 특성 고려
+
+## 7. [애정운 - 창의적 제목]
+예시 제목: "사랑이 찾아오는 계절" / "당신의 이상형, 사주가 말해주는 진짜 궁합"
+- ${genderStr === '남성' ? '재성' : '관성'} 기반 배우자 복 분석을 로맨틱하게
+- 연애 스타일, 배우자 상을 재미있게 묘사
+- 결혼 적령기를 부드럽게 안내
+
+## 8. [건강운 - 창의적 제목]
+예시 제목: "몸이 보내는 작은 신호들" / "100세까지 건강한 나를 위한 처방전"
+- 오행 과부족 → 주의할 건강 포인트를 걱정 안 되게 부드럽게
+- 구체적인 생활 습관 조언 (음식, 운동, 스트레스 관리)
+
+## 9. [현재 대운 - 창의적 제목]
+예시 제목: "지금 당신 앞에 펼쳐진 10년의 지도" / "인생의 봄이 오고 있어요"
+- ${daeunInfo}를 바탕으로 현재 10년의 의미를 이야기로
+- 지금 집중해야 할 것, 조심할 것을 친구처럼 조언
+
+## 10. [올해의 운세 - 창의적 제목] (${seun.year}년)
+예시 제목: "올해, 당신에게 찾아올 세 가지 기회" / "${seun.year}년은 당신의 해예요"
+- 세운 데이터 바탕으로 올해 키워드를 뽑아 설명
+- 상반기 vs 하반기 흐름
+- "이것만은 꼭!" 하는 핵심 조언
+
+## 11. [인생의 황금기 - 창의적 제목]
+예시 제목: "인생에서 가장 빛나는 순간이 다가오고 있어요" / "대박 터지는 그 시기"
+- 전체 대운 흐름에서 최고의 시기를 콕 집어서
+- 그 시기에 어떤 기회가 오는지 구체적이고 설레게
+- "그때를 위해 지금 준비할 것" 조언
+
+## 12. [종합 조언 - 창의적 제목]
+예시 제목: "당신이라는 별에게 보내는 편지" / "마지막으로 꼭 전하고 싶은 말"
+- 이 사주의 핵심 강점과 약점을 한 문장으로 요약
+- 용신(${ys.yongShinKr}) 활용 일상 팁
+- 진심 어린 응원과 철학적 메시지로 마무리
+- 마지막 문장은 감동적인 한 줄로 끝내주세요
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+[톤앤매너 - 가장 중요!!]
+- "~요" 체 친근한 말투 (절대 "~이다/한다" 체 사용 금지)
+- 전문 용어 최소화. 꼭 필요하면 비유로 풀어서 설명
+- 비유와 은유를 적극 활용 ("마치 ~처럼", "당신은 ~같은 사람이에요")
+- 중간중간 이모지를 자연스럽게 사용 (과하지 않게, 섹션당 1~2개)
+- 따끔한 조언 + 따뜻한 응원의 밸런스
+- "혹시 ~한 적 있지 않나요?" 같은 공감형 질문으로 몰입감 유도
+- Markdown 형식: ## 헤더, **볼드**, 리스트 활용
+- 각 섹션 제목은 반드시 번호 포함 (## 1. ~ ## 12.)
+- 읽는 사람이 "와, 이거 진짜 내 얘기다!" 하고 느끼게 만들어주세요`;
+
+ return systemPrompt;
+}
diff --git a/lib/saju-calculator.ts b/lib/saju-calculator.ts
new file mode 100644
index 0000000..b5fcaaa
--- /dev/null
+++ b/lib/saju-calculator.ts
@@ -0,0 +1,723 @@
+// 천간 (天干) - 10개
+export const HEAVENLY_STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'] as const;
+export const HEAVENLY_STEMS_KR = ['갑', '을', '병', '정', '무', '기', '경', '신', '임', '계'] as const;
+
+// 지지 (地支) - 12개
+export const EARTHLY_BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'] as const;
+export const EARTHLY_BRANCHES_KR = ['자', '축', '인', '묘', '진', '사', '오', '미', '신', '유', '술', '해'] as const;
+
+// 오행 (五行)
+export const FIVE_ELEMENTS = {
+ '甲': '木', '乙': '木',
+ '丙': '火', '丁': '火',
+ '戊': '土', '己': '土',
+ '庚': '金', '辛': '金',
+ '壬': '水', '癸': '水',
+ '寅': '木', '卯': '木',
+ '巳': '火', '午': '火',
+ '辰': '土', '戌': '土', '丑': '土', '未': '土',
+ '申': '金', '酉': '金',
+ '子': '水', '亥': '水',
+} as const;
+
+export const FIVE_ELEMENTS_KR = {
+ '木': '목', '火': '화', '土': '토', '金': '금', '水': '수'
+} as const;
+
+// 십성 (十星)
+export const TEN_GODS = {
+ same: { yang: '비견', yin: '겁재' }, // 같은 오행
+ produce: { yang: '식신', yin: '상관' }, // 내가 생하는 오행
+ overcome: { yang: '편재', yin: '정재' }, // 내가 극하는 오행
+ overcome_me: { yang: '편관', yin: '정관' }, // 나를 극하는 오행
+ produce_me: { yang: '편인', yin: '정인' } // 나를 생하는 오행
+} as const;
+
+// 십이운성 (十二運星)
+export const TWELVE_FORTUNES = [
+ '장생', '목욕', '관대', '건록', '제왕', '쇠', '병', '사', '묘', '절', '태', '양'
+] as const;
+
+// 간지 계산을 위한 기준일 (1900년 1월 1일 = 경자년 정축월 병인일)
+const BASE_YEAR = 1900;
+const BASE_YEAR_STEM = 6; // 庚
+const BASE_YEAR_BRANCH = 0; // 子
+
+/**
+ * 년도의 간지를 계산
+ */
+export function getYearGanzi(year: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
+ const yearDiff = year - BASE_YEAR;
+ const stemIndex = (BASE_YEAR_STEM + yearDiff) % 10;
+ const branchIndex = (BASE_YEAR_BRANCH + yearDiff) % 12;
+
+ return {
+ stem: HEAVENLY_STEMS[stemIndex],
+ branch: EARTHLY_BRANCHES[branchIndex],
+ stemKr: HEAVENLY_STEMS_KR[stemIndex],
+ branchKr: EARTHLY_BRANCHES_KR[branchIndex]
+ };
+}
+
+/**
+ * 월의 간지를 계산 (절기 기준)
+ */
+export function getMonthGanzi(year: number, month: number, day: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
+ // 절기 기준으로 월 지지 계산
+ const { getSolarTermMonthBranch } = require('./solar-terms');
+ const branchIndex = getSolarTermMonthBranch(year, month, day);
+
+ // 월 천간 계산 (년간에 따라 달라짐)
+ const yearStem = getYearGanzi(year).stem;
+ const yearStemIndex = HEAVENLY_STEMS.indexOf(yearStem as any);
+
+ // 월 천간 공식: (년간 * 2 + 월지지) % 10
+ const stemIndex = (yearStemIndex * 2 + branchIndex) % 10;
+
+ return {
+ stem: HEAVENLY_STEMS[stemIndex],
+ branch: EARTHLY_BRANCHES[branchIndex],
+ stemKr: HEAVENLY_STEMS_KR[stemIndex],
+ branchKr: EARTHLY_BRANCHES_KR[branchIndex]
+ };
+}
+
+/**
+ * 일의 간지를 계산 (만세력 기준)
+ */
+export function getDayGanzi(year: number, month: number, day: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
+ // 기준일 (1900-01-01) 부터의 일수 계산
+ const baseDate = new Date(1900, 0, 1);
+ const targetDate = new Date(year, month - 1, day);
+ const daysDiff = Math.floor((targetDate.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24));
+
+ // 1900-01-01 = 丙寅일
+ const baseDayStem = 2; // 丙
+ const baseDayBranch = 2; // 寅
+
+ const stemIndex = (baseDayStem + daysDiff) % 10;
+ const branchIndex = (baseDayBranch + daysDiff) % 12;
+
+ return {
+ stem: HEAVENLY_STEMS[stemIndex < 0 ? stemIndex + 10 : stemIndex],
+ branch: EARTHLY_BRANCHES[branchIndex < 0 ? branchIndex + 12 : branchIndex],
+ stemKr: HEAVENLY_STEMS_KR[stemIndex < 0 ? stemIndex + 10 : stemIndex],
+ branchKr: EARTHLY_BRANCHES_KR[branchIndex < 0 ? branchIndex + 12 : branchIndex]
+ };
+}
+
+/**
+ * 시의 간지를 계산
+ */
+export function getHourGanzi(dayGanzi: { stem: string }, hour: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
+ // 시 지지: 자시(23-01)=0, 축시(01-03)=1, ...
+ let branchIndex: number;
+
+ if (hour >= 23 || hour < 1) branchIndex = 0; // 子
+ else if (hour >= 1 && hour < 3) branchIndex = 1; // 丑
+ else if (hour >= 3 && hour < 5) branchIndex = 2; // 寅
+ else if (hour >= 5 && hour < 7) branchIndex = 3; // 卯
+ else if (hour >= 7 && hour < 9) branchIndex = 4; // 辰
+ else if (hour >= 9 && hour < 11) branchIndex = 5; // 巳
+ else if (hour >= 11 && hour < 13) branchIndex = 6; // 午
+ else if (hour >= 13 && hour < 15) branchIndex = 7; // 未
+ else if (hour >= 15 && hour < 17) branchIndex = 8; // 申
+ else if (hour >= 17 && hour < 19) branchIndex = 9; // 酉
+ else if (hour >= 19 && hour < 21) branchIndex = 10; // 戌
+ else branchIndex = 11; // 亥
+
+ // 시 천간 계산 (일간에 따라 달라짐)
+ const dayStemIndex = HEAVENLY_STEMS.indexOf(dayGanzi.stem as any);
+ const stemIndex = (dayStemIndex * 2 + branchIndex) % 10;
+
+ return {
+ stem: HEAVENLY_STEMS[stemIndex],
+ branch: EARTHLY_BRANCHES[branchIndex],
+ stemKr: HEAVENLY_STEMS_KR[stemIndex],
+ branchKr: EARTHLY_BRANCHES_KR[branchIndex]
+ };
+}
+
+/**
+ * 십성 계산
+ */
+export function getTenGod(dayStem: string, targetStem: string, isYang: boolean): string {
+ const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
+ const targetElement = FIVE_ELEMENTS[targetStem as keyof typeof FIVE_ELEMENTS];
+
+ // 같은 오행
+ if (dayElement === targetElement) {
+ return isYang ? '비견' : '겁재';
+ }
+
+ // 오행 상생/상극 관계 확인
+ const produceMap: { [key: string]: string } = {
+ '木': '火', '火': '土', '土': '金', '金': '水', '水': '木'
+ };
+ const overcomeMap: { [key: string]: string } = {
+ '木': '土', '火': '金', '土': '水', '金': '木', '水': '火'
+ };
+
+ // 내가 생하는 오행
+ if (produceMap[dayElement] === targetElement) {
+ return isYang ? '식신' : '상관';
+ }
+
+ // 내가 극하는 오행
+ if (overcomeMap[dayElement] === targetElement) {
+ return isYang ? '편재' : '정재';
+ }
+
+ // 나를 극하는 오행
+ if (overcomeMap[targetElement] === dayElement) {
+ return isYang ? '편관' : '정관';
+ }
+
+ // 나를 생하는 오행
+ if (produceMap[targetElement] === dayElement) {
+ return isYang ? '편인' : '정인';
+ }
+
+ return '비견';
+}
+
+/**
+ * 십이운성 계산
+ */
+export function getTwelveFortune(dayStem: string, branch: string): string {
+ // 간단한 십이운성 계산 (실제로는 더 복잡함)
+ const fortuneMap: { [key: string]: { [key: string]: number } } = {
+ '甲': { '亥': 11, '子': 0, '丑': 1, '寅': 2, '卯': 3, '辰': 4, '巳': 5, '午': 6, '未': 7, '申': 8, '酉': 9, '戌': 10 },
+ '乙': { '午': 11, '未': 0, '申': 1, '酉': 2, '戌': 3, '亥': 4, '子': 5, '丑': 6, '寅': 7, '卯': 8, '辰': 9, '巳': 10 },
+ '丙': { '寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10 },
+ '丁': { '酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10 },
+ '戊': { '寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10 },
+ '己': { '酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10 },
+ '庚': { '巳': 11, '午': 0, '未': 1, '申': 2, '酉': 3, '戌': 4, '亥': 5, '子': 6, '丑': 7, '寅': 8, '卯': 9, '辰': 10 },
+ '辛': { '子': 11, '丑': 0, '寅': 1, '卯': 2, '辰': 3, '巳': 4, '午': 5, '未': 6, '申': 7, '酉': 8, '戌': 9, '亥': 10 },
+ '壬': { '申': 11, '酉': 0, '戌': 1, '亥': 2, '子': 3, '丑': 4, '寅': 5, '卯': 6, '辰': 7, '巳': 8, '午': 9, '未': 10 },
+ '癸': { '卯': 11, '辰': 0, '巳': 1, '午': 2, '未': 3, '申': 4, '酉': 5, '戌': 6, '亥': 7, '子': 8, '丑': 9, '寅': 10 }
+ };
+
+ const index = fortuneMap[dayStem as keyof typeof fortuneMap]?.[branch as keyof typeof fortuneMap['甲']] ?? 0;
+ return TWELVE_FORTUNES[index];
+}
+
+/**
+ * 사주팔자 전체 계산
+ */
+export interface SajuData {
+ year: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
+ month: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
+ day: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
+ hour?: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
+ dayStem: string;
+ birthDate: { year: number; month: number; day: number; hour?: number };
+ gender: 'male' | 'female';
+}
+
+export function calculateSaju(
+ year: number,
+ month: number,
+ day: number,
+ hour: number | null,
+ gender: 'male' | 'female'
+): SajuData {
+ const yearGanzi = getYearGanzi(year);
+ const monthGanzi = getMonthGanzi(year, month, day);
+ const dayGanzi = getDayGanzi(year, month, day);
+ const hourGanzi = hour !== null ? getHourGanzi(dayGanzi, hour) : null;
+
+ const dayStem = dayGanzi.stem;
+ const dayStemIndex = HEAVENLY_STEMS.indexOf(dayStem as any);
+ const isDayYang = dayStemIndex % 2 === 0;
+
+ const result: SajuData = {
+ year: {
+ ...yearGanzi,
+ element: FIVE_ELEMENTS[yearGanzi.stem as keyof typeof FIVE_ELEMENTS],
+ tenGod: getTenGod(dayStem, yearGanzi.stem, (HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === 0) === isDayYang),
+ fortune: getTwelveFortune(dayStem, yearGanzi.branch)
+ },
+ month: {
+ ...monthGanzi,
+ element: FIVE_ELEMENTS[monthGanzi.stem as keyof typeof FIVE_ELEMENTS],
+ tenGod: getTenGod(dayStem, monthGanzi.stem, (HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === 0) === isDayYang),
+ fortune: getTwelveFortune(dayStem, monthGanzi.branch)
+ },
+ day: {
+ ...dayGanzi,
+ element: FIVE_ELEMENTS[dayGanzi.stem as keyof typeof FIVE_ELEMENTS],
+ tenGod: '일간',
+ fortune: getTwelveFortune(dayStem, dayGanzi.branch)
+ },
+ dayStem,
+ birthDate: { year, month, day, hour: hour ?? undefined },
+ gender
+ };
+
+ if (hourGanzi) {
+ result.hour = {
+ ...hourGanzi,
+ element: FIVE_ELEMENTS[hourGanzi.stem as keyof typeof FIVE_ELEMENTS],
+ tenGod: getTenGod(dayStem, hourGanzi.stem, (HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === 0) === isDayYang),
+ fortune: getTwelveFortune(dayStem, hourGanzi.branch)
+ };
+ }
+
+ return result;
+}
+
+// ============================================================
+// 지장간 (藏干) - 각 지지에 숨어있는 천간
+// ============================================================
+export const HIDDEN_STEMS: { [key: string]: string[] } = {
+ '子': ['癸'],
+ '丑': ['己', '癸', '辛'],
+ '寅': ['甲', '丙', '戊'],
+ '卯': ['乙'],
+ '辰': ['戊', '乙', '癸'],
+ '巳': ['丙', '庚', '戊'],
+ '午': ['丁', '己'],
+ '未': ['己', '丁', '乙'],
+ '申': ['庚', '壬', '戊'],
+ '酉': ['辛'],
+ '戌': ['戊', '辛', '丁'],
+ '亥': ['壬', '甲'],
+};
+
+/**
+ * 지지의 지장간(숨은 천간) 반환
+ */
+export function getHiddenStems(branch: string): string[] {
+ return HIDDEN_STEMS[branch] || [];
+}
+
+/**
+ * 4주 전체의 지장간 정보 반환
+ */
+export function getAllHiddenStems(saju: SajuData): { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[] {
+ const pillars = [
+ { pillar: '년주', branch: saju.year.branch, branchKr: saju.year.branchKr },
+ { pillar: '월주', branch: saju.month.branch, branchKr: saju.month.branchKr },
+ { pillar: '일주', branch: saju.day.branch, branchKr: saju.day.branchKr },
+ ];
+ if (saju.hour) {
+ pillars.push({ pillar: '시주', branch: saju.hour.branch, branchKr: saju.hour.branchKr });
+ }
+
+ return pillars.map(p => {
+ const hidden = getHiddenStems(p.branch);
+ return {
+ ...p,
+ stems: hidden.map((stem, idx) => {
+ const stemIndex = HEAVENLY_STEMS.indexOf(stem as any);
+ const role = idx === 0 ? '정기(본기)' : idx === 1 ? '중기' : '여기';
+ return {
+ stem,
+ stemKr: HEAVENLY_STEMS_KR[stemIndex],
+ element: FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS],
+ role,
+ };
+ }),
+ };
+ });
+}
+
+// ============================================================
+// 지지 상호작용 (合/沖/刑/破/害)
+// ============================================================
+
+export interface BranchInteraction {
+ type: string; // 육합, 삼합, 방합, 충, 형, 파, 해
+ branches: string[]; // 관련 지지 (한자)
+ branchesKr: string[]; // 관련 지지 (한글)
+ pillars: string[]; // 관련 기둥 (년주, 월주 등)
+ description: string;
+ resultElement?: string; // 합의 결과 오행 (해당 시)
+}
+
+const YUKAP_PAIRS: [string, string, string][] = [
+ ['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
+ ['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
+];
+
+const SAMHAP_GROUPS: [string, string, string, string][] = [
+ ['申', '子', '辰', '水'], ['亥', '卯', '未', '木'],
+ ['寅', '午', '戌', '火'], ['巳', '酉', '丑', '金'],
+];
+
+const BANGHAP_GROUPS: [string, string, string, string][] = [
+ ['寅', '卯', '辰', '木'], ['巳', '午', '未', '火'],
+ ['申', '酉', '戌', '金'], ['亥', '子', '丑', '水'],
+];
+
+const CHUNG_PAIRS: [string, string][] = [
+ ['子', '午'], ['丑', '未'], ['寅', '申'],
+ ['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
+];
+
+const HYUNG_GROUPS: { branches: string[]; name: string }[] = [
+ { branches: ['寅', '巳', '申'], name: '무은지형(無恩之刑)' },
+ { branches: ['丑', '戌', '未'], name: '지세지형(恃勢之刑)' },
+ { branches: ['子', '卯'], name: '무례지형(無禮之刑)' },
+];
+const JAHYUNG_BRANCHES = ['辰', '午', '酉', '亥'];
+
+const PA_PAIRS: [string, string][] = [
+ ['子', '酉'], ['丑', '辰'], ['寅', '亥'],
+ ['卯', '午'], ['巳', '申'], ['未', '戌'],
+];
+
+const HAE_PAIRS: [string, string][] = [
+ ['子', '未'], ['丑', '午'], ['寅', '巳'],
+ ['卯', '辰'], ['申', '亥'], ['酉', '戌'],
+];
+
+const ELEMENT_NAMES_KR: { [key: string]: string } = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
+
+/**
+ * 지지 상호작용 분석
+ */
+export function analyzeBranchInteractions(saju: SajuData): BranchInteraction[] {
+ const interactions: BranchInteraction[] = [];
+
+ // 기둥별 지지 수집
+ const pillarBranches: { branch: string; pillar: string; branchKr: string }[] = [
+ { branch: saju.year.branch, pillar: '년주', branchKr: saju.year.branchKr },
+ { branch: saju.month.branch, pillar: '월주', branchKr: saju.month.branchKr },
+ { branch: saju.day.branch, pillar: '일주', branchKr: saju.day.branchKr },
+ ];
+ if (saju.hour) {
+ pillarBranches.push({ branch: saju.hour.branch, pillar: '시주', branchKr: saju.hour.branchKr });
+ }
+
+ const branches = pillarBranches.map(p => p.branch);
+
+ // 육합 (六合) 검사
+ for (const [a, b, elem] of YUKAP_PAIRS) {
+ const idxA = branches.indexOf(a);
+ const idxB = branches.indexOf(b);
+ if (idxA !== -1 && idxB !== -1) {
+ interactions.push({
+ type: '육합(六合)',
+ branches: [a, b],
+ branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
+ pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
+ description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 육합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 기운 생성. 조화와 화합의 관계.`,
+ resultElement: elem,
+ });
+ }
+ }
+
+ // 삼합 (三合) 검사
+ for (const [a, b, c, elem] of SAMHAP_GROUPS) {
+ const found = [a, b, c].filter(x => branches.includes(x));
+ if (found.length >= 2) {
+ const foundPillars = found.map(x => {
+ const idx = branches.indexOf(x);
+ return pillarBranches[idx];
+ });
+ const isComplete = found.length === 3;
+ interactions.push({
+ type: isComplete ? '삼합(三合)' : '반삼합(半三合)',
+ branches: found,
+ branchesKr: foundPillars.map(p => p.branchKr),
+ pillars: foundPillars.map(p => p.pillar),
+ description: `${foundPillars.map(p => p.branchKr).join('')} ${isComplete ? '삼합' : '반삼합'} → ${ELEMENT_NAMES_KR[elem]}(${elem})국. ${isComplete ? '강력한 합의 기운.' : '삼합의 기운이 부분적으로 작용.'}`,
+ resultElement: elem,
+ });
+ }
+ }
+
+ // 방합 (方合) 검사
+ for (const [a, b, c, elem] of BANGHAP_GROUPS) {
+ const found = [a, b, c].filter(x => branches.includes(x));
+ if (found.length === 3) {
+ const foundPillars = found.map(x => {
+ const idx = branches.indexOf(x);
+ return pillarBranches[idx];
+ });
+ interactions.push({
+ type: '방합(方合)',
+ branches: found,
+ branchesKr: foundPillars.map(p => p.branchKr),
+ pillars: foundPillars.map(p => p.pillar),
+ description: `${foundPillars.map(p => p.branchKr).join('')} 방합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 방국. 매우 강한 오행 기운.`,
+ resultElement: elem,
+ });
+ }
+ }
+
+ // 충 (沖) 검사
+ for (const [a, b] of CHUNG_PAIRS) {
+ const idxA = branches.indexOf(a);
+ const idxB = branches.indexOf(b);
+ if (idxA !== -1 && idxB !== -1) {
+ interactions.push({
+ type: '충(沖)',
+ branches: [a, b],
+ branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
+ pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
+ description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 충 → 변동, 갈등, 변화의 에너지. ${pillarBranches[idxA].pillar}와 ${pillarBranches[idxB].pillar} 사이의 긴장 관계.`,
+ });
+ }
+ }
+
+ // 형 (刑) 검사
+ for (const group of HYUNG_GROUPS) {
+ const found = group.branches.filter(x => branches.includes(x));
+ if (found.length >= 2) {
+ const foundPillars = found.map(x => {
+ const idx = branches.indexOf(x);
+ return pillarBranches[idx];
+ });
+ interactions.push({
+ type: '형(刑)',
+ branches: found,
+ branchesKr: foundPillars.map(p => p.branchKr),
+ pillars: foundPillars.map(p => p.pillar),
+ description: `${foundPillars.map(p => p.branchKr).join('')} ${group.name} → 시련과 갈등의 기운. 주의가 필요한 관계.`,
+ });
+ }
+ }
+
+ // 자형 (自刑) 검사
+ for (const jb of JAHYUNG_BRANCHES) {
+ const count = branches.filter(x => x === jb).length;
+ if (count >= 2) {
+ const brKr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(jb as any)];
+ interactions.push({
+ type: '자형(自刑)',
+ branches: [jb, jb],
+ branchesKr: [brKr, brKr],
+ pillars: pillarBranches.filter(p => p.branch === jb).map(p => p.pillar),
+ description: `${brKr}${brKr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.`,
+ });
+ }
+ }
+
+ // 파 (破) 검사
+ for (const [a, b] of PA_PAIRS) {
+ const idxA = branches.indexOf(a);
+ const idxB = branches.indexOf(b);
+ if (idxA !== -1 && idxB !== -1) {
+ interactions.push({
+ type: '파(破)',
+ branches: [a, b],
+ branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
+ pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
+ description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 파 → 관계의 균열, 계획의 차질 가능성.`,
+ });
+ }
+ }
+
+ // 해 (害) 검사
+ for (const [a, b] of HAE_PAIRS) {
+ const idxA = branches.indexOf(a);
+ const idxB = branches.indexOf(b);
+ if (idxA !== -1 && idxB !== -1) {
+ interactions.push({
+ type: '해(害)',
+ branches: [a, b],
+ branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
+ pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
+ description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 해 → 은근한 방해, 원망의 기운.`,
+ });
+ }
+ }
+
+ return interactions;
+}
+
+// ============================================================
+// 신살 (神煞) 계산
+// ============================================================
+
+export interface Shinsal {
+ name: string;
+ nameHanja: string;
+ branch: string;
+ branchKr: string;
+ pillar: string;
+ description: string;
+}
+
+// 일지 삼합국 기준 신살 매핑
+const SAMHAP_GROUP_MAP: { [key: string]: string } = {
+ '申': '申子辰', '子': '申子辰', '辰': '申子辰',
+ '寅': '寅午戌', '午': '寅午戌', '戌': '寅午戌',
+ '巳': '巳酉丑', '酉': '巳酉丑', '丑': '巳酉丑',
+ '亥': '亥卯未', '卯': '亥卯未', '未': '亥卯未',
+};
+
+const YEOKMA_MAP: { [key: string]: string } = {
+ '申子辰': '寅', '寅午戌': '申', '巳酉丑': '亥', '亥卯未': '巳',
+};
+const DOHWA_MAP: { [key: string]: string } = {
+ '申子辰': '酉', '寅午戌': '卯', '巳酉丑': '午', '亥卯未': '子',
+};
+const HWAGAE_MAP: { [key: string]: string } = {
+ '申子辰': '辰', '寅午戌': '戌', '巳酉丑': '丑', '亥卯未': '未',
+};
+
+// 천을귀인 (天乙貴人) - 일간 기준
+const CHEONUL_MAP: { [key: string]: string[] } = {
+ '甲': ['丑', '未'], '乙': ['子', '申'], '丙': ['亥', '酉'], '丁': ['亥', '酉'],
+ '戊': ['丑', '未'], '己': ['子', '申'], '庚': ['丑', '未'], '辛': ['寅', '午'],
+ '壬': ['卯', '巳'], '癸': ['卯', '巳'],
+};
+
+// 문창귀인 (文昌貴人) - 일간 기준
+const MUNCHANG_MAP: { [key: string]: string } = {
+ '甲': '巳', '乙': '午', '丙': '申', '丁': '酉',
+ '戊': '申', '己': '酉', '庚': '亥', '辛': '子',
+ '壬': '寅', '癸': '卯',
+};
+
+// 천덕귀인 (天德貴人) - 월지 기준
+const CHEONDUK_MAP: { [key: string]: string } = {
+ '寅': '丁', '卯': '申', '辰': '壬', '巳': '辛',
+ '午': '亥', '未': '甲', '申': '癸', '酉': '寅',
+ '戌': '丙', '亥': '乙', '子': '巳', '丑': '庚',
+};
+
+/**
+ * 신살 계산
+ */
+export function calculateShinsal(saju: SajuData): Shinsal[] {
+ const result: Shinsal[] = [];
+ const dayBranch = saju.day.branch;
+ const dayStem = saju.dayStem;
+ const monthBranch = saju.month.branch;
+
+ // 4주의 지지 수집
+ const pillarBranches: { branch: string; branchKr: string; pillar: string }[] = [
+ { branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
+ { branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
+ { branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
+ ];
+ if (saju.hour) {
+ pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
+ }
+
+ const group = SAMHAP_GROUP_MAP[dayBranch];
+
+ // 역마살
+ if (group) {
+ const yeokma = YEOKMA_MAP[group];
+ for (const pb of pillarBranches) {
+ if (pb.branch === yeokma && pb.pillar !== '일주') {
+ result.push({
+ name: '역마살', nameHanja: '驛馬殺', branch: yeokma,
+ branchKr: pb.branchKr, pillar: pb.pillar,
+ description: '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
+ });
+ }
+ }
+
+ // 도화살
+ const dohwa = DOHWA_MAP[group];
+ for (const pb of pillarBranches) {
+ if (pb.branch === dohwa && pb.pillar !== '일주') {
+ result.push({
+ name: '도화살', nameHanja: '桃花殺', branch: dohwa,
+ branchKr: pb.branchKr, pillar: pb.pillar,
+ description: '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
+ });
+ }
+ }
+
+ // 화개살
+ const hwagae = HWAGAE_MAP[group];
+ for (const pb of pillarBranches) {
+ if (pb.branch === hwagae && pb.pillar !== '일주') {
+ result.push({
+ name: '화개살', nameHanja: '華蓋殺', branch: hwagae,
+ branchKr: pb.branchKr, pillar: pb.pillar,
+ description: '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
+ });
+ }
+ }
+ }
+
+ // 천을귀인
+ const cheonulBranches = CHEONUL_MAP[dayStem] || [];
+ for (const pb of pillarBranches) {
+ if (cheonulBranches.includes(pb.branch) && pb.pillar !== '일주') {
+ result.push({
+ name: '천을귀인', nameHanja: '天乙貴人', branch: pb.branch,
+ branchKr: pb.branchKr, pillar: pb.pillar,
+ description: '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
+ });
+ }
+ }
+
+ // 문창귀인
+ const munchangBranch = MUNCHANG_MAP[dayStem];
+ if (munchangBranch) {
+ for (const pb of pillarBranches) {
+ if (pb.branch === munchangBranch && pb.pillar !== '일주') {
+ result.push({
+ name: '문창귀인', nameHanja: '文昌貴人', branch: pb.branch,
+ branchKr: pb.branchKr, pillar: pb.pillar,
+ description: '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
+ });
+ }
+ }
+ }
+
+ // 천덕귀인 (월지 기준, 천간에서 확인)
+ const cheondukStem = CHEONDUK_MAP[monthBranch];
+ if (cheondukStem) {
+ const allStems = [
+ { stem: saju.year.stem, pillar: '년주' },
+ { stem: saju.day.stem, pillar: '일주' },
+ ];
+ if (saju.hour) allStems.push({ stem: saju.hour.stem, pillar: '시주' });
+ for (const ps of allStems) {
+ if (ps.stem === cheondukStem) {
+ result.push({
+ name: '천덕귀인', nameHanja: '天德貴人', branch: monthBranch,
+ branchKr: EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(monthBranch as any)],
+ pillar: ps.pillar,
+ description: '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
+ });
+ }
+ }
+ }
+
+ return result;
+}
+
+// ============================================================
+// 공망 (空亡) 계산
+// ============================================================
+
+/**
+ * 60갑자에서 일주의 순(旬)을 찾아 공망 지지 2개를 반환
+ */
+export function calculateGongmang(dayStem: string, dayBranch: string): { branches: string[]; branchesKr: string[]; description: string } {
+ const stemIdx = HEAVENLY_STEMS.indexOf(dayStem as any);
+ const branchIdx = EARTHLY_BRANCHES.indexOf(dayBranch as any);
+
+ // 60갑자에서 해당 순(旬)의 시작점 = 천간이 甲인 지점
+ // 순의 시작 지지 인덱스 = (branchIdx - stemIdx + 12) % 12
+ const startBranchIdx = (branchIdx - stemIdx + 120) % 12;
+
+ // 공망 = 순에 포함되지 않는 2개의 지지
+ // 순은 10개의 간지 → 10개의 지지 사용, 2개가 남음
+ const gongmang1Idx = (startBranchIdx + 10) % 12;
+ const gongmang2Idx = (startBranchIdx + 11) % 12;
+
+ const branch1 = EARTHLY_BRANCHES[gongmang1Idx];
+ const branch2 = EARTHLY_BRANCHES[gongmang2Idx];
+ const branchKr1 = EARTHLY_BRANCHES_KR[gongmang1Idx];
+ const branchKr2 = EARTHLY_BRANCHES_KR[gongmang2Idx];
+
+ return {
+ branches: [branch1, branch2],
+ branchesKr: [branchKr1, branchKr2],
+ description: `${branchKr1}(${branch1})·${branchKr2}(${branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.`,
+ };
+}
diff --git a/lib/solar-terms.ts b/lib/solar-terms.ts
new file mode 100644
index 0000000..9753e57
--- /dev/null
+++ b/lib/solar-terms.ts
@@ -0,0 +1,249 @@
+/**
+ * 24절기 계산
+ * 사주 계산에서 월주는 절기를 기준으로 합니다.
+ */
+
+// 24절기 (입춘부터 시작)
+export const SOLAR_TERMS = [
+ '입춘', '우수', '경칩', '춘분', '청명', '곡우',
+ '입하', '소만', '망종', '하지', '소서', '대서',
+ '입추', '처서', '백로', '추분', '한로', '상강',
+ '입동', '소설', '대설', '동지', '소한', '대한'
+] as const;
+
+// 월 절기 (홀수 인덱스: 입X, 짝수 인덱스: X분/X지)
+export const MONTH_SOLAR_TERMS = [
+ '입춘', // 1월 (인월)
+ '경칩', // 2월 (묘월)
+ '청명', // 3월 (진월)
+ '입하', // 4월 (사월)
+ '망종', // 5월 (오월)
+ '소서', // 6월 (미월)
+ '입추', // 7월 (신월)
+ '백로', // 8월 (유월)
+ '한로', // 9월 (술월)
+ '입동', // 10월 (해월)
+ '대설', // 11월 (자월)
+ '소한', // 12월 (축월)
+] as const;
+
+interface SolarTermDate {
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+ minute: number;
+}
+
+/**
+ * 정밀한 절기 계산 (천문학적 계산 기반)
+ * solarlunar 라이브러리 사용
+ */
+export function getSolarTermDate(year: number, termIndex: number): SolarTermDate {
+ try {
+ const solarLunar = require('solarlunar');
+
+ // solarlunar의 절기 데이터 가져오기
+ // 각 년도의 절기 정보를 계산
+ const termNames = [
+ '立春', '雨水', '驚蟄', '春分', '清明', '穀雨',
+ '立夏', '小滿', '芒種', '夏至', '小暑', '大暑',
+ '立秋', '處暑', '白露', '秋分', '寒露', '霜降',
+ '立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
+ ];
+
+ // 해당 년도의 절기 찾기
+ // solarlunar는 양력 날짜로 절기 확인 가능
+ // 각 절기의 대략적인 날짜 범위에서 검색
+
+ const searchRanges = [
+ { month: 2, startDay: 3, endDay: 5 }, // 입춘
+ { month: 2, startDay: 18, endDay: 20 }, // 우수
+ { month: 3, startDay: 5, endDay: 7 }, // 경칩
+ { month: 3, startDay: 20, endDay: 22 }, // 춘분
+ { month: 4, startDay: 4, endDay: 6 }, // 청명
+ { month: 4, startDay: 19, endDay: 21 }, // 곡우
+ { month: 5, startDay: 5, endDay: 7 }, // 입하
+ { month: 5, startDay: 20, endDay: 22 }, // 소만
+ { month: 6, startDay: 5, endDay: 7 }, // 망종
+ { month: 6, startDay: 20, endDay: 22 }, // 하지
+ { month: 7, startDay: 6, endDay: 8 }, // 소서
+ { month: 7, startDay: 22, endDay: 24 }, // 대서
+ { month: 8, startDay: 7, endDay: 9 }, // 입추
+ { month: 8, startDay: 22, endDay: 24 }, // 처서
+ { month: 9, startDay: 7, endDay: 9 }, // 백로
+ { month: 9, startDay: 22, endDay: 24 }, // 추분
+ { month: 10, startDay: 7, endDay: 9 }, // 한로
+ { month: 10, startDay: 23, endDay: 24 },// 상강
+ { month: 11, startDay: 7, endDay: 8 }, // 입동
+ { month: 11, startDay: 21, endDay: 23 },// 소설
+ { month: 12, startDay: 6, endDay: 8 }, // 대설
+ { month: 12, startDay: 21, endDay: 23 },// 동지
+ { month: 1, startDay: 5, endDay: 7 }, // 소한
+ { month: 1, startDay: 19, endDay: 21 }, // 대한
+ ];
+
+ const range = searchRanges[termIndex];
+ const termName = termNames[termIndex];
+
+ // 해당 범위 내에서 절기 찾기
+ for (let day = range.startDay; day <= range.endDay; day++) {
+ const lunar = solarLunar.solar2lunar(year, range.month, day);
+ if (lunar && lunar.term === termName) {
+ return {
+ year,
+ month: range.month,
+ day,
+ hour: 0,
+ minute: 0
+ };
+ }
+ }
+
+ // 찾지 못한 경우 중간값 사용
+ const midDay = Math.floor((range.startDay + range.endDay) / 2);
+ return {
+ year,
+ month: range.month,
+ day: midDay,
+ hour: 0,
+ minute: 0
+ };
+
+ } catch (error) {
+ console.error('절기 계산 오류:', error);
+
+ // 폴백: 기존 근사값 사용
+ const baseMonth = [
+ 2, 2, 3, 3, 4, 4,
+ 5, 5, 6, 6, 7, 7,
+ 8, 8, 9, 9, 10, 10,
+ 11, 11, 12, 12, 1, 1
+ ];
+
+ const baseDay = [
+ 4, 19, 5, 20, 4, 20,
+ 5, 21, 6, 21, 7, 23,
+ 7, 23, 8, 23, 8, 23,
+ 7, 22, 7, 22, 5, 20
+ ];
+
+ return {
+ year,
+ month: baseMonth[termIndex],
+ day: baseDay[termIndex],
+ hour: 0,
+ minute: 0
+ };
+ }
+}
+
+/**
+ * 주어진 날짜가 어느 절기 이후인지 확인
+ * @param year 년
+ * @param month 월
+ * @param day 일
+ * @returns 절기 인덱스 (0~23)
+ */
+export function getCurrentSolarTerm(year: number, month: number, day: number): number {
+ const date = new Date(year, month - 1, day);
+ const dateValue = date.getTime();
+
+ // 각 절기 날짜 확인
+ for (let i = 23; i >= 0; i--) {
+ const termDate = getSolarTermDate(year, i);
+ let termYear = termDate.year;
+ let termMonth = termDate.month;
+
+ // 대한, 소한은 이전 해 처리
+ if (i >= 22 && month >= 2) {
+ termYear = year;
+ } else if (i >= 22) {
+ termYear = year - 1;
+ }
+
+ const term = new Date(termYear, termMonth - 1, termDate.day);
+
+ if (dateValue >= term.getTime()) {
+ return i;
+ }
+ }
+
+ // 입춘 이전이면 전년도 대한 이후
+ return 23;
+}
+
+/**
+ * 절기 기준 월주 지지 인덱스 계산
+ * @param year 년
+ * @param month 월
+ * @param day 일
+ * @returns 지지 인덱스 (0: 자, 1: 축, 2: 인, ...)
+ */
+export function getSolarTermMonthBranch(year: number, month: number, day: number): number {
+ const termIndex = getCurrentSolarTerm(year, month, day);
+
+ // 절기 인덱스를 월로 변환
+ // 입춘(0) -> 인월(2)
+ // 경칩(2) -> 묘월(3)
+ // 청명(4) -> 진월(4)
+ // ...
+
+ const monthBranches = [
+ 2, // 입춘 -> 인월
+ 2, // 우수 -> 인월
+ 3, // 경칩 -> 묘월
+ 3, // 춘분 -> 묘월
+ 4, // 청명 -> 진월
+ 4, // 곡우 -> 진월
+ 5, // 입하 -> 사월
+ 5, // 소만 -> 사월
+ 6, // 망종 -> 오월
+ 6, // 하지 -> 오월
+ 7, // 소서 -> 미월
+ 7, // 대서 -> 미월
+ 8, // 입추 -> 신월
+ 8, // 처서 -> 신월
+ 9, // 백로 -> 유월
+ 9, // 추분 -> 유월
+ 10, // 한로 -> 술월
+ 10, // 상강 -> 술월
+ 11, // 입동 -> 해월
+ 11, // 소설 -> 해월
+ 0, // 대설 -> 자월
+ 0, // 동지 -> 자월
+ 1, // 소한 -> 축월
+ 1, // 대한 -> 축월
+ ];
+
+ return monthBranches[termIndex];
+}
+
+/**
+ * 절기명 가져오기
+ */
+export function getSolarTermName(termIndex: number): string {
+ return SOLAR_TERMS[termIndex];
+}
+
+/**
+ * 다음 절기까지 남은 일수 계산
+ */
+export function getDaysToNextSolarTerm(year: number, month: number, day: number): number {
+ const currentDate = new Date(year, month - 1, day);
+ const currentTerm = getCurrentSolarTerm(year, month, day);
+ const nextTermIndex = (currentTerm + 1) % 24;
+
+ let nextYear = year;
+ if (currentTerm === 23) {
+ nextYear = year + 1;
+ }
+
+ const nextTerm = getSolarTermDate(nextYear, nextTermIndex);
+ const nextDate = new Date(nextTerm.year, nextTerm.month - 1, nextTerm.day);
+
+ const diffTime = nextDate.getTime() - currentDate.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ return diffDays;
+}
diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts
new file mode 100644
index 0000000..36f55e3
--- /dev/null
+++ b/lib/supabase/client.ts
@@ -0,0 +1,7 @@
+import { createBrowserClient } from '@supabase/ssr';
+
+export function createClient() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co';
+ const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-key';
+ return createBrowserClient(url, key);
+}
diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts
new file mode 100644
index 0000000..6f51dc8
--- /dev/null
+++ b/lib/supabase/server.ts
@@ -0,0 +1,27 @@
+import { createServerClient, type CookieMethodsServer } from '@supabase/ssr';
+import { cookies } from 'next/headers';
+
+export async function createClient() {
+ const cookieStore = await cookies();
+
+ const cookieMethods: CookieMethodsServer = {
+ getAll() {
+ return cookieStore.getAll();
+ },
+ setAll(cookiesToSet) {
+ try {
+ cookiesToSet.forEach(({ name, value, options }) =>
+ cookieStore.set(name, value, options)
+ );
+ } catch {
+ // Server Component에서 호출된 경우 무시 (미들웨어가 세션 갱신)
+ }
+ },
+ };
+
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ { cookies: cookieMethods }
+ );
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..593b130
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,12 @@
+import { type NextRequest } from 'next/server';
+import { updateSession } from '@/utils/supabase/middleware';
+
+export async function middleware(request: NextRequest) {
+ return await updateSession(request);
+}
+
+export const config = {
+ matcher: [
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+};
diff --git a/package-lock.json b/package-lock.json
index d640cfa..049fde6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,17 @@
"name": "jaengseung-made",
"version": "0.1.0",
"dependencies": {
+ "@supabase/ssr": "^0.5.2",
+ "@supabase/supabase-js": "^2.99.0",
+ "@tosspayments/tosspayments-sdk": "^2.6.0",
"next": "16.1.6",
+ "openai": "^6.21.0",
"react": "19.2.3",
"react-dom": "19.2.3",
- "resend": "^6.9.1"
+ "react-markdown": "^9.0.1",
+ "remark-gfm": "^4.0.0",
+ "resend": "^6.9.1",
+ "solarlunar": "^2.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1253,6 +1260,99 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.0.tgz",
+ "integrity": "sha512-tHiIST/OEoLmWBE+3X69xRY5srJM/lL86KltmMlIfDo9ePJLo14vQQV9T4NF+P+MoGhCwQL1GTmk51zuAFMXKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.0.tgz",
+ "integrity": "sha512-zA9oad6EqGwMLLu2LfP1bXbqKcJGiotAdbdTfZG7YS7619YZQAEgejj9mp+E5vglKE1yMWbKK+S1J3PbuUtgLg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.0.tgz",
+ "integrity": "sha512-8qfOMi2pu9y0IQhUAeFqjrvR49G4ELGevXCWV9qAHXFQ/h2FFh0I8PYjFQj4rHcHSq6hrpozDnS1vbQU8NAQ/A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.0.tgz",
+ "integrity": "sha512-7nFTZhNeANR7FvEY6PfWLCfE8dHqcaJd9SuR7IPEZvBPG9K4uEHMivpjZx4NWRSU7Eji7ZbKy2LG+cJ48DhwHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.5.2.tgz",
+ "integrity": "sha512-n3plRhr2Bs8Xun1o4S3k1CDv17iH5QY9YcoEvXX3bxV1/5XSasA0mNXYycFmADIdtdE6BG9MRjP5CGIs8qxC8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/cookie": "^0.6.0",
+ "cookie": "^0.7.0"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.43.4"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.0.tgz",
+ "integrity": "sha512-mAEEbfsght5EEALejYrwAP9k8sFBGjfMZT8n4SyMXk2iYuWVeRMs1kA/uKg0uDMctWdZ0bL+L4jZzksUJpCjMA==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.99.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.0.tgz",
+ "integrity": "sha512-SP9Sn9tsHDB7N4u2gT13rdeZJewE4xibAxasG7vOz+fYi92+XkMMbWNx0uGK53zKTnAnvTs16isRooyBy4sn5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.99.0",
+ "@supabase/functions-js": "2.99.0",
+ "@supabase/postgrest-js": "2.99.0",
+ "@supabase/realtime-js": "2.99.0",
+ "@supabase/storage-js": "2.99.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1533,6 +1633,12 @@
"tailwindcss": "4.1.18"
}
},
+ "node_modules/@tosspayments/tosspayments-sdk": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@tosspayments/tosspayments-sdk/-/tosspayments-sdk-2.6.0.tgz",
+ "integrity": "sha512-d/9tCj5d+Jbj312bWYXo9dtGNzQaWRxTKWmw6rTwuDgw5g4mrJWV9dP9qiN6/x9PYaphbYDjxGenguHoVQXTGA==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1544,13 +1650,45 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1565,21 +1703,40 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1595,6 +1752,21 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
@@ -1864,6 +2036,12 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
+ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "license": "ISC"
+ },
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
@@ -2430,6 +2608,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2584,6 +2772,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2601,6 +2799,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2627,6 +2865,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2641,6 +2889,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2660,7 +2917,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2728,7 +2984,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2742,6 +2997,19 @@
}
}
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2794,6 +3062,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2804,6 +3081,19 @@
"node": ">=8"
}
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3550,6 +3840,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3560,6 +3860,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3995,6 +4301,46 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -4037,6 +4383,16 @@
"node": ">=14"
}
},
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -4056,6 +4412,15 @@
"entities": "^4.4.0"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
@@ -4109,6 +4474,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4124,6 +4495,30 @@
"node": ">= 0.4"
}
},
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4282,6 +4677,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4341,6 +4746,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -4394,6 +4809,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5046,6 +5473,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5097,6 +5534,16 @@
"tlds": "1.261.0"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5107,6 +5554,288 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5117,6 +5846,569 @@
"node": ">= 8"
}
},
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -5158,7 +6450,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5422,6 +6713,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/openai": {
+ "version": "6.27.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
+ "integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "openai": "bin/cli"
+ },
+ "peerDependencies": {
+ "ws": "^8.18.0",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "ws": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5503,6 +6815,31 @@
"node": ">=6"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@@ -5632,6 +6969,16 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5700,6 +7047,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
+ "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5744,6 +7118,72 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/resend": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.1.tgz",
@@ -6136,6 +7576,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/solarlunar": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/solarlunar/-/solarlunar-2.0.7.tgz",
+ "integrity": "sha512-2SfuCCgAAxFU5MTMYuKGbRgRLcPTJQf3azMEw/GmBpHXA7N2eAQJStSqktZJjnq4qRCboBPnqEB866+PCregag==",
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6145,6 +7591,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6289,6 +7745,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -6312,6 +7782,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6462,6 +7950,26 @@
"node": ">=8.0"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -6665,9 +8173,95 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -6757,6 +8351,34 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6872,6 +8494,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6896,7 +8539,7 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -6914,6 +8557,16 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/package.json b/package.json
index d4961b9..1e4a675 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,17 @@
"lint": "eslint"
},
"dependencies": {
+ "@supabase/ssr": "^0.5.2",
+ "@supabase/supabase-js": "^2.99.0",
+ "@tosspayments/tosspayments-sdk": "^2.6.0",
"next": "16.1.6",
+ "openai": "^6.21.0",
"react": "19.2.3",
"react-dom": "19.2.3",
- "resend": "^6.9.1"
+ "react-markdown": "^9.0.1",
+ "remark-gfm": "^4.0.0",
+ "resend": "^6.9.1",
+ "solarlunar": "^2.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/saju-engine/Dockerfile b/saju-engine/Dockerfile
new file mode 100644
index 0000000..e09a8ce
--- /dev/null
+++ b/saju-engine/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.12-slim
+
+# 시스템 패키지 (ephem 빌드에 필요)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# 의존성 먼저 복사 (레이어 캐시 활용)
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# 소스 복사
+COPY . .
+
+# 비루트 사용자 실행
+RUN useradd -m appuser && chown -R appuser:appuser /app
+USER appuser
+
+EXPOSE 8000
+
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
diff --git a/saju-engine/calculator/__init__.py b/saju-engine/calculator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/saju-engine/calculator/daeun_calculator.py b/saju-engine/calculator/daeun_calculator.py
new file mode 100644
index 0000000..e7173db
--- /dev/null
+++ b/saju-engine/calculator/daeun_calculator.py
@@ -0,0 +1,109 @@
+"""
+대운 (大運) 계산 모듈
+양남음녀 순행, 음남양녀 역행, 절기 기준 대운 시작 나이
+"""
+
+from calculator.saju_calculator import HEAVENLY_STEMS, EARTHLY_BRANCHES, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES_KR
+from calculator.solar_terms import get_days_to_next_solar_term, get_days_from_prev_solar_term
+
+
+def _calculate_daeun_start_age(
+ birth_year: int,
+ birth_month: int,
+ birth_day: int,
+ gender: str,
+ is_yang_year: bool,
+) -> int:
+ """절기 기준 대운 시작 나이 계산"""
+ is_forward = (gender == 'male' and is_yang_year) or (gender == 'female' and not is_yang_year)
+
+ if is_forward:
+ # 순행: 생일부터 다음 절기까지의 일수
+ days = get_days_to_next_solar_term(birth_year, birth_month, birth_day)
+ else:
+ # 역행: 이전 절기부터 생일까지의 일수
+ days = get_days_from_prev_solar_term(birth_year, birth_month, birth_day)
+
+ # 3일 = 1세
+ start_age = days // 3
+ return max(1, min(10, start_age))
+
+
+def calculate_daeun(
+ birth_year: int,
+ birth_month: int,
+ birth_day: int,
+ gender: str,
+ month_stem: str,
+ month_branch: str,
+) -> list[dict]:
+ """대운 계산 (10년 단위, 8개 대운)"""
+ if month_stem not in HEAVENLY_STEMS or month_branch not in EARTHLY_BRANCHES:
+ return []
+
+ month_stem_idx = HEAVENLY_STEMS.index(month_stem)
+ month_branch_idx = EARTHLY_BRANCHES.index(month_branch)
+
+ year_stem_idx = (birth_year - 1900 + 6) % 10
+ is_yang_year = year_stem_idx % 2 == 0
+
+ is_forward = (gender == 'male' and is_yang_year) or (gender == 'female' and not is_yang_year)
+
+ start_age = _calculate_daeun_start_age(birth_year, birth_month, birth_day, gender, is_yang_year)
+
+ daeun_list = []
+ for i in range(8):
+ age = start_age + (i * 10)
+ start_year = birth_year + age
+ end_year = start_year + 9
+
+ if is_forward:
+ stem_idx = (month_stem_idx + i + 1) % 10
+ branch_idx = (month_branch_idx + i + 1) % 12
+ else:
+ stem_idx = (month_stem_idx - i - 1 + 100) % 10
+ branch_idx = (month_branch_idx - i - 1 + 120) % 12
+
+ daeun_list.append({
+ 'age': age,
+ 'startYear': start_year,
+ 'endYear': end_year,
+ 'stem': HEAVENLY_STEMS[stem_idx],
+ 'branch': EARTHLY_BRANCHES[branch_idx],
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
+ })
+
+ return daeun_list
+
+
+def get_current_daeun(daeun_list: list[dict], current_year: int) -> dict | None:
+ """현재 대운 찾기"""
+ for daeun in daeun_list:
+ if daeun['startYear'] <= current_year <= daeun['endYear']:
+ return daeun
+ return None
+
+
+def get_daeun_description(daeun: dict, day_stem: str) -> str:
+ """대운 기본 해석"""
+ age = daeun['age']
+ ganzi = f"{daeun['stem']}{daeun['branch']}"
+ desc = f"{age}세부터 {age + 9}세까지의 10년은 {daeun['stemKr']}{daeun['branchKr']}({ganzi}) 대운입니다. "
+
+ if age < 20:
+ desc += '청소년기로 학업과 기초를 다지는 시기입니다. '
+ elif age < 40:
+ desc += '성장과 발전의 시기로 사회활동이 왕성한 때입니다. '
+ elif age < 60:
+ desc += '안정과 성숙의 시기로 경험이 쌓이는 때입니다. '
+ else:
+ desc += '원숙한 시기로 인생의 지혜를 나누는 때입니다. '
+
+ stem_idx = HEAVENLY_STEMS.index(daeun['stem'])
+ if stem_idx % 2 == 0:
+ desc += '적극적이고 외향적인 활동이 유리합니다.'
+ else:
+ desc += '차분하고 내실을 다지는 것이 좋습니다.'
+
+ return desc
diff --git a/saju-engine/calculator/lotto_generator.py b/saju-engine/calculator/lotto_generator.py
new file mode 100644
index 0000000..4e9a3ee
--- /dev/null
+++ b/saju-engine/calculator/lotto_generator.py
@@ -0,0 +1,267 @@
+"""
+사주 기반 로또 번호 생성 모듈
+오행 균형, 천간/지지 고유 숫자, 신살 등을 반영
+"""
+
+import hashlib
+import random
+from typing import Optional
+
+from calculator.saju_calculator import FIVE_ELEMENTS
+
+# 오행별 로또 번호 후보
+_ELEMENT_NUMBERS: dict[str, list[int]] = {
+ '木': [1, 2, 11, 12, 21, 22, 31, 32, 41, 42],
+ '火': [3, 4, 13, 14, 23, 24, 33, 34, 43, 44],
+ '土': [5, 6, 15, 16, 25, 26, 35, 36, 45],
+ '金': [7, 8, 17, 18, 27, 28, 37, 38],
+ '水': [9, 10, 19, 20, 29, 30, 39, 40],
+}
+
+# 천간 고유 숫자 (각 천간에 대응하는 행운 숫자)
+_STEM_NUMBERS: dict[str, list[int]] = {
+ '甲': [1, 11, 21, 31, 41],
+ '乙': [2, 12, 22, 32, 42],
+ '丙': [3, 13, 23, 33, 43],
+ '丁': [4, 14, 24, 34, 44],
+ '戊': [5, 15, 25, 35, 45],
+ '己': [6, 16, 26, 36],
+ '庚': [7, 17, 27, 37],
+ '辛': [8, 18, 28, 38],
+ '壬': [9, 19, 29, 39],
+ '癸': [10, 20, 30, 40],
+}
+
+# 지지 고유 숫자
+_BRANCH_NUMBERS: dict[str, list[int]] = {
+ '子': [9, 19, 29, 39],
+ '丑': [6, 15, 25, 36],
+ '寅': [1, 11, 31, 41],
+ '卯': [2, 12, 22, 42],
+ '辰': [5, 16, 26, 35],
+ '巳': [3, 14, 24, 43],
+ '午': [4, 13, 23, 44],
+ '未': [6, 16, 26, 45],
+ '申': [7, 18, 27, 37],
+ '酉': [8, 17, 28, 38],
+ '戌': [5, 15, 25, 35],
+ '亥': [10, 20, 30, 40],
+}
+
+# 신살별 보너스 숫자
+_SHINSAL_BONUS: dict[str, list[int]] = {
+ '역마살': [7, 17, 27, 37],
+ '도화살': [3, 13, 23, 33, 43],
+ '화개살': [11, 22, 33, 44],
+ '천을귀인': [1, 7, 14, 21, 28, 35, 42],
+ '문창귀인': [4, 16, 25, 36],
+ '천덕귀인': [6, 12, 24, 36],
+}
+
+
+def _seed_from_saju(saju: dict) -> str:
+ """사주 데이터에서 결정론적 시드 생성"""
+ bd = saju.get('birthDate', {})
+ key = (
+ f"{bd.get('year')}-{bd.get('month')}-{bd.get('day')}-"
+ f"{bd.get('hour', 'X')}-{saju.get('gender', 'X')}"
+ )
+ return hashlib.sha256(key.encode()).hexdigest()
+
+
+def _get_dominant_elements(saju: dict) -> list[str]:
+ """사주에서 강한 오행 추출 (빈도 기준 정렬)"""
+ count: dict[str, int] = {'木': 0, '火': 0, '土': 0, '金': 0, '水': 0}
+ pillars = ['year', 'month', 'day', 'hour']
+ for p in pillars:
+ pillar = saju.get(p)
+ if not pillar:
+ continue
+ for key in ['stem', 'branch']:
+ char = pillar.get(key, '')
+ elem = FIVE_ELEMENTS.get(char)
+ if elem:
+ count[elem] = count.get(elem, 0) + 1
+
+ return sorted(count, key=lambda e: count[e], reverse=True)
+
+
+def generate_lotto_numbers(
+ saju: dict,
+ shinsal: Optional[list[dict]] = None,
+ count: int = 6,
+) -> dict:
+ """
+ 사주 기반 로또 번호 생성
+
+ Returns:
+ {
+ 'numbers': [int, ...], # 추천 번호 (오름차순)
+ 'basis': str, # 생성 근거 설명
+ 'elementBalance': dict, # 오행별 번호 분포
+ }
+ """
+ seed_hex = _seed_from_saju(saju)
+ rng = random.Random(int(seed_hex, 16) % (2**32))
+
+ # 1. 후보 풀 구성 (우선순위 점수)
+ scores: dict[int, float] = {n: 0.0 for n in range(1, 46)}
+
+ # 오행 비중 (강한 오행 우선)
+ dominant_elements = _get_dominant_elements(saju)
+ for rank, elem in enumerate(dominant_elements):
+ weight = 5.0 - rank # 1위=5점, 2위=4점, ...
+ for n in _ELEMENT_NUMBERS.get(elem, []):
+ if n in scores:
+ scores[n] += weight
+
+ # 일간 비중
+ day_stem = saju.get('dayStem', '')
+ for n in _STEM_NUMBERS.get(day_stem, []):
+ if n in scores:
+ scores[n] += 4.0
+
+ # 일지 비중
+ day_branch = saju.get('day', {}).get('branch', '')
+ for n in _BRANCH_NUMBERS.get(day_branch, []):
+ if n in scores:
+ scores[n] += 3.0
+
+ # 월지 비중
+ month_branch = saju.get('month', {}).get('branch', '')
+ for n in _BRANCH_NUMBERS.get(month_branch, []):
+ if n in scores:
+ scores[n] += 2.0
+
+ # 신살 보너스
+ shinsal_names = []
+ if shinsal:
+ for s in shinsal:
+ name = s.get('name', '')
+ shinsal_names.append(name)
+ for n in _SHINSAL_BONUS.get(name, []):
+ if n in scores:
+ scores[n] += 2.5
+
+ # 2. 점수 기반 확률 가중 샘플링
+ numbers_pool = list(scores.keys())
+ weights = [scores[n] + 1.0 for n in numbers_pool] # 최소 1.0 보장
+
+ selected: list[int] = []
+ remaining_pool = list(zip(numbers_pool, weights))
+
+ while len(selected) < count and remaining_pool:
+ total = sum(w for _, w in remaining_pool)
+ pick = rng.uniform(0, total)
+ cumulative = 0
+ picked_n = None
+ for n, w in remaining_pool:
+ cumulative += w
+ if pick <= cumulative:
+ picked_n = n
+ break
+ if picked_n is None:
+ picked_n = remaining_pool[-1][0]
+
+ selected.append(picked_n)
+ remaining_pool = [(n, w) for n, w in remaining_pool if n != picked_n]
+
+ selected.sort()
+
+ # 3. 오행 분포 계산
+ def _number_to_element(n: int) -> str:
+ for elem, nums in _ELEMENT_NUMBERS.items():
+ if n in nums:
+ return elem
+ return '土'
+
+ element_balance = {}
+ for n in selected:
+ elem = _number_to_element(n)
+ element_balance[elem] = element_balance.get(elem, [])
+ element_balance[elem].append(n)
+
+ # 4. 근거 설명 생성
+ basis_parts = [
+ f"일간 {saju.get('dayStem', '')}({day_stem}) 기반",
+ f"강한 오행: {', '.join(dominant_elements[:2])}",
+ ]
+ if shinsal_names:
+ basis_parts.append(f"신살 반영: {', '.join(set(shinsal_names))}")
+
+ basis = ' / '.join(basis_parts)
+
+ return {
+ 'numbers': selected,
+ 'basis': basis,
+ 'elementBalance': element_balance,
+ }
+
+
+def generate_multiple_sets(
+ saju: dict,
+ shinsal: Optional[list[dict]] = None,
+ sets: int = 5,
+) -> list[dict]:
+ """여러 세트의 로또 번호 생성 (시드 변형)"""
+ results = []
+ seed_hex = _seed_from_saju(saju)
+ base_seed = int(seed_hex, 16) % (2**32)
+
+ for i in range(sets):
+ # 세트별 시드 변형
+ modified_saju = dict(saju)
+ modified_saju['_set_index'] = i # 내부 변형용
+
+ rng = random.Random(base_seed + i * 997)
+
+ dominant_elements = _get_dominant_elements(saju)
+
+ # 각 세트는 조금씩 다른 오행 강조
+ scores: dict[int, float] = {n: rng.random() * 2 for n in range(1, 46)}
+
+ elem_to_emphasize = dominant_elements[i % len(dominant_elements)]
+ for n in _ELEMENT_NUMBERS.get(elem_to_emphasize, []):
+ if n in scores:
+ scores[n] += 5.0
+
+ day_stem = saju.get('dayStem', '')
+ for n in _STEM_NUMBERS.get(day_stem, []):
+ if n in scores:
+ scores[n] += 3.0
+
+ if shinsal:
+ for s in shinsal:
+ for n in _SHINSAL_BONUS.get(s.get('name', ''), []):
+ if n in scores:
+ scores[n] += 2.0
+
+ pool = list(scores.keys())
+ weights = [scores[n] for n in pool]
+
+ selected: list[int] = []
+ remaining = list(zip(pool, weights))
+
+ while len(selected) < 6 and remaining:
+ total = sum(w for _, w in remaining)
+ pick = rng.uniform(0, total)
+ cumulative = 0.0
+ picked_n = None
+ for n, w in remaining:
+ cumulative += w
+ if pick <= cumulative:
+ picked_n = n
+ break
+ if picked_n is None:
+ picked_n = remaining[-1][0]
+ selected.append(picked_n)
+ remaining = [(n, w) for n, w in remaining if n != picked_n]
+
+ selected.sort()
+ results.append({
+ 'set': i + 1,
+ 'numbers': selected,
+ 'emphasis': elem_to_emphasize,
+ })
+
+ return results
diff --git a/saju-engine/calculator/saju_calculator.py b/saju-engine/calculator/saju_calculator.py
new file mode 100644
index 0000000..9395bd1
--- /dev/null
+++ b/saju-engine/calculator/saju_calculator.py
@@ -0,0 +1,596 @@
+"""
+사주팔자 계산 모듈
+천간, 지지, 오행, 십성, 십이운성, 신살, 공망, 지장간, 지지 상호작용
+"""
+
+from datetime import date, datetime
+from typing import Optional
+from calculator.solar_terms import get_solar_term_month_branch
+
+# ============================================================
+# 기본 상수
+# ============================================================
+
+HEAVENLY_STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸']
+HEAVENLY_STEMS_KR = ['갑', '을', '병', '정', '무', '기', '경', '신', '임', '계']
+
+EARTHLY_BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥']
+EARTHLY_BRANCHES_KR = ['자', '축', '인', '묘', '진', '사', '오', '미', '신', '유', '술', '해']
+
+FIVE_ELEMENTS: dict[str, str] = {
+ '甲': '木', '乙': '木',
+ '丙': '火', '丁': '火',
+ '戊': '土', '己': '土',
+ '庚': '金', '辛': '金',
+ '壬': '水', '癸': '水',
+ '寅': '木', '卯': '木',
+ '巳': '火', '午': '火',
+ '辰': '土', '戌': '土', '丑': '土', '未': '土',
+ '申': '金', '酉': '金',
+ '子': '水', '亥': '水',
+}
+
+FIVE_ELEMENTS_KR = {'木': '목', '火': '화', '土': '토', '金': '금', '水': '수'}
+
+TWELVE_FORTUNES = ['장생', '목욕', '관대', '건록', '제왕', '쇠', '병', '사', '묘', '절', '태', '양']
+
+# 기준년: 1900 = 庚子년
+BASE_YEAR = 1900
+BASE_YEAR_STEM = 6 # 庚
+BASE_YEAR_BRANCH = 0 # 子
+
+# 기준일: 1900-01-01 = 丙寅일
+BASE_DAY_STEM = 2 # 丙
+BASE_DAY_BRANCH = 2 # 寅
+
+# ============================================================
+# 간지 계산
+# ============================================================
+
+def get_year_ganzi(year: int) -> dict:
+ year_diff = year - BASE_YEAR
+ stem_idx = (BASE_YEAR_STEM + year_diff) % 10
+ branch_idx = (BASE_YEAR_BRANCH + year_diff) % 12
+ return {
+ 'stem': HEAVENLY_STEMS[stem_idx],
+ 'branch': EARTHLY_BRANCHES[branch_idx],
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
+ }
+
+
+def get_month_ganzi(year: int, month: int, day: int) -> dict:
+ branch_idx = get_solar_term_month_branch(year, month, day)
+ year_stem = get_year_ganzi(year)['stem']
+ year_stem_idx = HEAVENLY_STEMS.index(year_stem)
+ stem_idx = (year_stem_idx * 2 + branch_idx) % 10
+ return {
+ 'stem': HEAVENLY_STEMS[stem_idx],
+ 'branch': EARTHLY_BRANCHES[branch_idx],
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
+ }
+
+
+def get_day_ganzi(year: int, month: int, day: int) -> dict:
+ base = date(1900, 1, 1)
+ target = date(year, month, day)
+ days_diff = (target - base).days
+
+ stem_idx = (BASE_DAY_STEM + days_diff) % 10
+ branch_idx = (BASE_DAY_BRANCH + days_diff) % 12
+
+ # 음수 처리 (1900년 이전)
+ if stem_idx < 0:
+ stem_idx += 10
+ if branch_idx < 0:
+ branch_idx += 12
+
+ return {
+ 'stem': HEAVENLY_STEMS[stem_idx],
+ 'branch': EARTHLY_BRANCHES[branch_idx],
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
+ }
+
+
+def get_hour_ganzi(day_stem: str, hour: int) -> dict:
+ if hour >= 23 or hour < 1:
+ branch_idx = 0 # 子
+ elif hour < 3:
+ branch_idx = 1 # 丑
+ elif hour < 5:
+ branch_idx = 2 # 寅
+ elif hour < 7:
+ branch_idx = 3 # 卯
+ elif hour < 9:
+ branch_idx = 4 # 辰
+ elif hour < 11:
+ branch_idx = 5 # 巳
+ elif hour < 13:
+ branch_idx = 6 # 午
+ elif hour < 15:
+ branch_idx = 7 # 未
+ elif hour < 17:
+ branch_idx = 8 # 申
+ elif hour < 19:
+ branch_idx = 9 # 酉
+ elif hour < 21:
+ branch_idx = 10 # 戌
+ else:
+ branch_idx = 11 # 亥
+
+ day_stem_idx = HEAVENLY_STEMS.index(day_stem)
+ stem_idx = (day_stem_idx * 2 + branch_idx) % 10
+ return {
+ 'stem': HEAVENLY_STEMS[stem_idx],
+ 'branch': EARTHLY_BRANCHES[branch_idx],
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
+ }
+
+
+# ============================================================
+# 십성 계산
+# ============================================================
+
+_PRODUCE_MAP = {'木': '火', '火': '土', '土': '金', '金': '水', '水': '木'}
+_OVERCOME_MAP = {'木': '土', '火': '金', '土': '水', '金': '木', '水': '火'}
+
+
+def get_ten_god(day_stem: str, target_stem: str, is_yang: bool) -> str:
+ day_elem = FIVE_ELEMENTS.get(day_stem, '')
+ target_elem = FIVE_ELEMENTS.get(target_stem, '')
+
+ if day_elem == target_elem:
+ return '비견' if is_yang else '겁재'
+ if _PRODUCE_MAP.get(day_elem) == target_elem:
+ return '식신' if is_yang else '상관'
+ if _OVERCOME_MAP.get(day_elem) == target_elem:
+ return '편재' if is_yang else '정재'
+ if _OVERCOME_MAP.get(target_elem) == day_elem:
+ return '편관' if is_yang else '정관'
+ if _PRODUCE_MAP.get(target_elem) == day_elem:
+ return '편인' if is_yang else '정인'
+ return '비견'
+
+
+# ============================================================
+# 십이운성 계산
+# ============================================================
+
+_FORTUNE_MAP: dict[str, dict[str, int]] = {
+ '甲': {'亥': 11, '子': 0, '丑': 1, '寅': 2, '卯': 3, '辰': 4, '巳': 5, '午': 6, '未': 7, '申': 8, '酉': 9, '戌': 10},
+ '乙': {'午': 11, '未': 0, '申': 1, '酉': 2, '戌': 3, '亥': 4, '子': 5, '丑': 6, '寅': 7, '卯': 8, '辰': 9, '巳': 10},
+ '丙': {'寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10},
+ '丁': {'酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10},
+ '戊': {'寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10},
+ '己': {'酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10},
+ '庚': {'巳': 11, '午': 0, '未': 1, '申': 2, '酉': 3, '戌': 4, '亥': 5, '子': 6, '丑': 7, '寅': 8, '卯': 9, '辰': 10},
+ '辛': {'子': 11, '丑': 0, '寅': 1, '卯': 2, '辰': 3, '巳': 4, '午': 5, '未': 6, '申': 7, '酉': 8, '戌': 9, '亥': 10},
+ '壬': {'申': 11, '酉': 0, '戌': 1, '亥': 2, '子': 3, '丑': 4, '寅': 5, '卯': 6, '辰': 7, '巳': 8, '午': 9, '未': 10},
+ '癸': {'卯': 11, '辰': 0, '巳': 1, '午': 2, '未': 3, '申': 4, '酉': 5, '戌': 6, '亥': 7, '子': 8, '丑': 9, '寅': 10},
+}
+
+
+def get_twelve_fortune(day_stem: str, branch: str) -> str:
+ idx = _FORTUNE_MAP.get(day_stem, {}).get(branch, 0)
+ return TWELVE_FORTUNES[idx]
+
+
+# ============================================================
+# 지장간 (藏干)
+# ============================================================
+
+HIDDEN_STEMS: dict[str, list[str]] = {
+ '子': ['癸'],
+ '丑': ['己', '癸', '辛'],
+ '寅': ['甲', '丙', '戊'],
+ '卯': ['乙'],
+ '辰': ['戊', '乙', '癸'],
+ '巳': ['丙', '庚', '戊'],
+ '午': ['丁', '己'],
+ '未': ['己', '丁', '乙'],
+ '申': ['庚', '壬', '戊'],
+ '酉': ['辛'],
+ '戌': ['戊', '辛', '丁'],
+ '亥': ['壬', '甲'],
+}
+
+_ROLE_NAMES = ['정기(본기)', '중기', '여기']
+
+
+def get_hidden_stems(branch: str) -> list[str]:
+ return HIDDEN_STEMS.get(branch, [])
+
+
+def get_all_hidden_stems(saju: dict) -> list[dict]:
+ pillars = [
+ {'pillar': '년주', 'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr']},
+ {'pillar': '월주', 'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr']},
+ {'pillar': '일주', 'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr']},
+ ]
+ if saju.get('hour'):
+ pillars.append({'pillar': '시주', 'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr']})
+
+ result = []
+ for p in pillars:
+ hidden = get_hidden_stems(p['branch'])
+ stems_info = []
+ for idx, stem in enumerate(hidden):
+ stem_idx = HEAVENLY_STEMS.index(stem)
+ stems_info.append({
+ 'stem': stem,
+ 'stemKr': HEAVENLY_STEMS_KR[stem_idx],
+ 'element': FIVE_ELEMENTS.get(stem, ''),
+ 'role': _ROLE_NAMES[idx] if idx < len(_ROLE_NAMES) else '여기',
+ })
+ result.append({
+ 'pillar': p['pillar'],
+ 'branch': p['branch'],
+ 'branchKr': p['branchKr'],
+ 'stems': stems_info,
+ })
+ return result
+
+
+# ============================================================
+# 지지 상호작용
+# ============================================================
+
+_YUKAP_PAIRS = [
+ ('子', '丑', '土'), ('寅', '亥', '木'), ('卯', '戌', '火'),
+ ('辰', '酉', '金'), ('巳', '申', '水'), ('午', '未', '火'),
+]
+
+_SAMHAP_GROUPS = [
+ ('申', '子', '辰', '水'), ('亥', '卯', '未', '木'),
+ ('寅', '午', '戌', '火'), ('巳', '酉', '丑', '金'),
+]
+
+_BANGHAP_GROUPS = [
+ ('寅', '卯', '辰', '木'), ('巳', '午', '未', '火'),
+ ('申', '酉', '戌', '金'), ('亥', '子', '丑', '水'),
+]
+
+_CHUNG_PAIRS = [
+ ('子', '午'), ('丑', '未'), ('寅', '申'),
+ ('卯', '酉'), ('辰', '戌'), ('巳', '亥'),
+]
+
+_HYUNG_GROUPS = [
+ {'branches': ['寅', '巳', '申'], 'name': '무은지형(無恩之刑)'},
+ {'branches': ['丑', '戌', '未'], 'name': '지세지형(恃勢之刑)'},
+ {'branches': ['子', '卯'], 'name': '무례지형(無禮之刑)'},
+]
+
+_JAHYUNG_BRANCHES = ['辰', '午', '酉', '亥']
+
+_PA_PAIRS = [
+ ('子', '酉'), ('丑', '辰'), ('寅', '亥'),
+ ('卯', '午'), ('巳', '申'), ('未', '戌'),
+]
+
+_HAE_PAIRS = [
+ ('子', '未'), ('丑', '午'), ('寅', '巳'),
+ ('卯', '辰'), ('申', '亥'), ('酉', '戌'),
+]
+
+_ELEM_KR = {'木': '목', '火': '화', '土': '토', '金': '금', '水': '수'}
+
+
+def analyze_branch_interactions(saju: dict) -> list[dict]:
+ pillar_branches = [
+ {'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr'], 'pillar': '년주'},
+ {'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr'], 'pillar': '월주'},
+ {'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr'], 'pillar': '일주'},
+ ]
+ if saju.get('hour'):
+ pillar_branches.append({'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr'], 'pillar': '시주'})
+
+ branches = [p['branch'] for p in pillar_branches]
+ interactions = []
+
+ # 육합
+ for a, b, elem in _YUKAP_PAIRS:
+ if a in branches and b in branches:
+ idx_a = branches.index(a)
+ idx_b = branches.index(b)
+ interactions.append({
+ 'type': '육합(六合)',
+ 'branches': [a, b],
+ 'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
+ 'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
+ 'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 육합 → {_ELEM_KR.get(elem, '')}({elem}) 기운 생성. 조화와 화합의 관계.",
+ 'resultElement': elem,
+ })
+
+ # 삼합
+ for a, b, c, elem in _SAMHAP_GROUPS:
+ found = [x for x in [a, b, c] if x in branches]
+ if len(found) >= 2:
+ found_pillars = [pillar_branches[branches.index(x)] for x in found]
+ is_complete = len(found) == 3
+ interactions.append({
+ 'type': '삼합(三合)' if is_complete else '반삼합(半三合)',
+ 'branches': found,
+ 'branchesKr': [p['branchKr'] for p in found_pillars],
+ 'pillars': [p['pillar'] for p in found_pillars],
+ 'description': f"{''.join(p['branchKr'] for p in found_pillars)} {'삼합' if is_complete else '반삼합'} → {_ELEM_KR.get(elem, '')}({elem})국.",
+ 'resultElement': elem,
+ })
+
+ # 방합
+ for a, b, c, elem in _BANGHAP_GROUPS:
+ found = [x for x in [a, b, c] if x in branches]
+ if len(found) == 3:
+ found_pillars = [pillar_branches[branches.index(x)] for x in found]
+ interactions.append({
+ 'type': '방합(方合)',
+ 'branches': found,
+ 'branchesKr': [p['branchKr'] for p in found_pillars],
+ 'pillars': [p['pillar'] for p in found_pillars],
+ 'description': f"{''.join(p['branchKr'] for p in found_pillars)} 방합 → {_ELEM_KR.get(elem, '')}({elem}) 방국. 매우 강한 오행 기운.",
+ 'resultElement': elem,
+ })
+
+ # 충
+ for a, b in _CHUNG_PAIRS:
+ if a in branches and b in branches:
+ idx_a = branches.index(a)
+ idx_b = branches.index(b)
+ interactions.append({
+ 'type': '충(沖)',
+ 'branches': [a, b],
+ 'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
+ 'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
+ 'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 충 → 변동, 갈등, 변화의 에너지.",
+ })
+
+ # 형
+ for group in _HYUNG_GROUPS:
+ found = [x for x in group['branches'] if x in branches]
+ if len(found) >= 2:
+ found_pillars = [pillar_branches[branches.index(x)] for x in found]
+ interactions.append({
+ 'type': '형(刑)',
+ 'branches': found,
+ 'branchesKr': [p['branchKr'] for p in found_pillars],
+ 'pillars': [p['pillar'] for p in found_pillars],
+ 'description': f"{''.join(p['branchKr'] for p in found_pillars)} {group['name']} → 시련과 갈등의 기운.",
+ })
+
+ # 자형
+ for jb in _JAHYUNG_BRANCHES:
+ count = branches.count(jb)
+ if count >= 2:
+ br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(jb)]
+ interactions.append({
+ 'type': '자형(自刑)',
+ 'branches': [jb, jb],
+ 'branchesKr': [br_kr, br_kr],
+ 'pillars': [p['pillar'] for p in pillar_branches if p['branch'] == jb],
+ 'description': f'{br_kr}{br_kr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.',
+ })
+
+ # 파
+ for a, b in _PA_PAIRS:
+ if a in branches and b in branches:
+ idx_a = branches.index(a)
+ idx_b = branches.index(b)
+ interactions.append({
+ 'type': '파(破)',
+ 'branches': [a, b],
+ 'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
+ 'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
+ 'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 파 → 관계의 균열, 계획의 차질 가능성.",
+ })
+
+ # 해
+ for a, b in _HAE_PAIRS:
+ if a in branches and b in branches:
+ idx_a = branches.index(a)
+ idx_b = branches.index(b)
+ interactions.append({
+ 'type': '해(害)',
+ 'branches': [a, b],
+ 'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
+ 'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
+ 'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 해 → 은근한 방해, 원망의 기운.",
+ })
+
+ return interactions
+
+
+# ============================================================
+# 신살 (神煞)
+# ============================================================
+
+_SAMHAP_GROUP_MAP: dict[str, str] = {
+ '申': '申子辰', '子': '申子辰', '辰': '申子辰',
+ '寅': '寅午戌', '午': '寅午戌', '戌': '寅午戌',
+ '巳': '巳酉丑', '酉': '巳酉丑', '丑': '巳酉丑',
+ '亥': '亥卯未', '卯': '亥卯未', '未': '亥卯未',
+}
+
+_YEOKMA_MAP = {'申子辰': '寅', '寅午戌': '申', '巳酉丑': '亥', '亥卯未': '巳'}
+_DOHWA_MAP = {'申子辰': '酉', '寅午戌': '卯', '巳酉丑': '午', '亥卯未': '子'}
+_HWAGAE_MAP = {'申子辰': '辰', '寅午戌': '戌', '巳酉丑': '丑', '亥卯未': '未'}
+
+_CHEONUL_MAP: dict[str, list[str]] = {
+ '甲': ['丑', '未'], '乙': ['子', '申'], '丙': ['亥', '酉'], '丁': ['亥', '酉'],
+ '戊': ['丑', '未'], '己': ['子', '申'], '庚': ['丑', '未'], '辛': ['寅', '午'],
+ '壬': ['卯', '巳'], '癸': ['卯', '巳'],
+}
+
+_MUNCHANG_MAP: dict[str, str] = {
+ '甲': '巳', '乙': '午', '丙': '申', '丁': '酉',
+ '戊': '申', '己': '酉', '庚': '亥', '辛': '子',
+ '壬': '寅', '癸': '卯',
+}
+
+_CHEONDUK_MAP: dict[str, str] = {
+ '寅': '丁', '卯': '申', '辰': '壬', '巳': '辛',
+ '午': '亥', '未': '甲', '申': '癸', '酉': '寅',
+ '戌': '丙', '亥': '乙', '子': '巳', '丑': '庚',
+}
+
+
+def calculate_shinsal(saju: dict) -> list[dict]:
+ result = []
+ day_branch = saju['day']['branch']
+ day_stem = saju['dayStem']
+ month_branch = saju['month']['branch']
+
+ pillar_branches = [
+ {'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr'], 'pillar': '년주'},
+ {'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr'], 'pillar': '월주'},
+ {'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr'], 'pillar': '일주'},
+ ]
+ if saju.get('hour'):
+ pillar_branches.append({'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr'], 'pillar': '시주'})
+
+ group = _SAMHAP_GROUP_MAP.get(day_branch)
+ if group:
+ # 역마살
+ yeokma = _YEOKMA_MAP[group]
+ for pb in pillar_branches:
+ if pb['branch'] == yeokma and pb['pillar'] != '일주':
+ result.append({
+ 'name': '역마살', 'nameHanja': '驛馬殺',
+ 'branch': yeokma, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
+ 'description': '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
+ })
+
+ # 도화살
+ dohwa = _DOHWA_MAP[group]
+ for pb in pillar_branches:
+ if pb['branch'] == dohwa and pb['pillar'] != '일주':
+ result.append({
+ 'name': '도화살', 'nameHanja': '桃花殺',
+ 'branch': dohwa, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
+ 'description': '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
+ })
+
+ # 화개살
+ hwagae = _HWAGAE_MAP[group]
+ for pb in pillar_branches:
+ if pb['branch'] == hwagae and pb['pillar'] != '일주':
+ result.append({
+ 'name': '화개살', 'nameHanja': '華蓋殺',
+ 'branch': hwagae, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
+ 'description': '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
+ })
+
+ # 천을귀인
+ cheonul_branches = _CHEONUL_MAP.get(day_stem, [])
+ for pb in pillar_branches:
+ if pb['branch'] in cheonul_branches and pb['pillar'] != '일주':
+ result.append({
+ 'name': '천을귀인', 'nameHanja': '天乙貴人',
+ 'branch': pb['branch'], 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
+ 'description': '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
+ })
+
+ # 문창귀인
+ munchang_branch = _MUNCHANG_MAP.get(day_stem)
+ if munchang_branch:
+ for pb in pillar_branches:
+ if pb['branch'] == munchang_branch and pb['pillar'] != '일주':
+ result.append({
+ 'name': '문창귀인', 'nameHanja': '文昌貴人',
+ 'branch': pb['branch'], 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
+ 'description': '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
+ })
+
+ # 천덕귀인 (월지 기준, 천간에서 확인)
+ cheonduk_stem = _CHEONDUK_MAP.get(month_branch)
+ if cheonduk_stem:
+ all_stems = [
+ {'stem': saju['year']['stem'], 'pillar': '년주'},
+ {'stem': saju['day']['stem'], 'pillar': '일주'},
+ ]
+ if saju.get('hour'):
+ all_stems.append({'stem': saju['hour']['stem'], 'pillar': '시주'})
+ for ps in all_stems:
+ if ps['stem'] == cheonduk_stem:
+ result.append({
+ 'name': '천덕귀인', 'nameHanja': '天德貴人',
+ 'branch': month_branch,
+ 'branchKr': EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(month_branch)],
+ 'pillar': ps['pillar'],
+ 'description': '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
+ })
+
+ return result
+
+
+# ============================================================
+# 공망 (空亡)
+# ============================================================
+
+def calculate_gongmang(day_stem: str, day_branch: str) -> dict:
+ stem_idx = HEAVENLY_STEMS.index(day_stem)
+ branch_idx = EARTHLY_BRANCHES.index(day_branch)
+
+ start_branch_idx = (branch_idx - stem_idx + 120) % 12
+ gm1 = (start_branch_idx + 10) % 12
+ gm2 = (start_branch_idx + 11) % 12
+
+ branch1 = EARTHLY_BRANCHES[gm1]
+ branch2 = EARTHLY_BRANCHES[gm2]
+ br_kr1 = EARTHLY_BRANCHES_KR[gm1]
+ br_kr2 = EARTHLY_BRANCHES_KR[gm2]
+
+ return {
+ 'branches': [branch1, branch2],
+ 'branchesKr': [br_kr1, br_kr2],
+ 'description': f'{br_kr1}({branch1})·{br_kr2}({branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.',
+ }
+
+
+# ============================================================
+# 사주팔자 전체 계산
+# ============================================================
+
+def calculate_saju(
+ year: int,
+ month: int,
+ day: int,
+ hour: Optional[int],
+ gender: str,
+) -> dict:
+ year_ganzi = get_year_ganzi(year)
+ month_ganzi = get_month_ganzi(year, month, day)
+ day_ganzi = get_day_ganzi(year, month, day)
+ hour_ganzi = get_hour_ganzi(day_ganzi['stem'], hour) if hour is not None else None
+
+ day_stem = day_ganzi['stem']
+ day_stem_idx = HEAVENLY_STEMS.index(day_stem)
+ is_day_yang = day_stem_idx % 2 == 0
+
+ def enrich(ganzi: dict, is_day_pillar: bool = False) -> dict:
+ stem = ganzi['stem']
+ branch = ganzi['branch']
+ stem_idx = HEAVENLY_STEMS.index(stem)
+ is_yang = (stem_idx % 2 == 0) == is_day_yang
+ return {
+ **ganzi,
+ 'element': FIVE_ELEMENTS.get(stem, ''),
+ 'tenGod': '일간' if is_day_pillar else get_ten_god(day_stem, stem, is_yang),
+ 'fortune': get_twelve_fortune(day_stem, branch),
+ }
+
+ saju: dict = {
+ 'year': enrich(year_ganzi),
+ 'month': enrich(month_ganzi),
+ 'day': enrich(day_ganzi, is_day_pillar=True),
+ 'dayStem': day_stem,
+ 'birthDate': {'year': year, 'month': month, 'day': day, 'hour': hour},
+ 'gender': gender,
+ }
+
+ if hour_ganzi:
+ saju['hour'] = enrich(hour_ganzi)
+
+ return saju
diff --git a/saju-engine/calculator/solar_terms.py b/saju-engine/calculator/solar_terms.py
new file mode 100644
index 0000000..33b6e50
--- /dev/null
+++ b/saju-engine/calculator/solar_terms.py
@@ -0,0 +1,191 @@
+"""
+24절기 계산 모듈
+ephem 라이브러리를 사용한 정밀한 절기 날짜 계산
+"""
+
+import ephem
+import math
+from datetime import datetime, date, timedelta
+from typing import Optional
+
+# 24절기 이름 (한글)
+SOLAR_TERMS = [
+ '입춘', '우수', '경칩', '춘분', '청명', '곡우',
+ '입하', '소만', '망종', '하지', '소서', '대서',
+ '입추', '처서', '백로', '추분', '한로', '상강',
+ '입동', '소설', '대설', '동지', '소한', '대한'
+]
+
+# 각 절기에 대응하는 태양황경 (도)
+SOLAR_TERM_ANGLES = [
+ 315, 330, 345, 0, 15, 30,
+ 45, 60, 75, 90, 105, 120,
+ 135, 150, 165, 180, 195, 210,
+ 225, 240, 255, 270, 285, 300
+]
+
+# 절기별 대략적인 월
+SOLAR_TERM_BASE_MONTHS = [
+ 2, 2, 3, 3, 4, 4,
+ 5, 5, 6, 6, 7, 7,
+ 8, 8, 9, 9, 10, 10,
+ 11, 11, 12, 12, 1, 1
+]
+
+# 절기별 대략적인 일
+SOLAR_TERM_BASE_DAYS = [
+ 4, 19, 5, 20, 4, 20,
+ 5, 21, 6, 21, 7, 23,
+ 7, 23, 8, 23, 8, 23,
+ 7, 22, 7, 22, 5, 20
+]
+
+# 절기 → 월지지 인덱스 매핑
+# 입춘(0) → 인월(2), 우수(1) → 인월(2), ...
+TERM_TO_MONTH_BRANCH = [
+ 2, # 입춘 → 인월
+ 2, # 우수 → 인월
+ 3, # 경칩 → 묘월
+ 3, # 춘분 → 묘월
+ 4, # 청명 → 진월
+ 4, # 곡우 → 진월
+ 5, # 입하 → 사월
+ 5, # 소만 → 사월
+ 6, # 망종 → 오월
+ 6, # 하지 → 오월
+ 7, # 소서 → 미월
+ 7, # 대서 → 미월
+ 8, # 입추 → 신월
+ 8, # 처서 → 신월
+ 9, # 백로 → 유월
+ 9, # 추분 → 유월
+ 10, # 한로 → 술월
+ 10, # 상강 → 술월
+ 11, # 입동 → 해월
+ 11, # 소설 → 해월
+ 0, # 대설 → 자월
+ 0, # 동지 → 자월
+ 1, # 소한 → 축월
+ 1, # 대한 → 축월
+]
+
+
+def _get_solar_longitude(dt: datetime) -> float:
+ """주어진 날짜시간의 태양황경 계산 (ephem 사용)"""
+ sun = ephem.Sun()
+ sun.compute(dt.strftime('%Y/%m/%d %H:%M:%S'))
+ ecl = ephem.Ecliptic(sun)
+ return math.degrees(ecl.lon) % 360
+
+
+def _get_solar_term_date_ephem(year: int, term_index: int) -> Optional[date]:
+ """ephem을 사용해 특정 절기 날짜 계산"""
+ target_angle = SOLAR_TERM_ANGLES[term_index]
+ base_month = SOLAR_TERM_BASE_MONTHS[term_index]
+ base_day = SOLAR_TERM_BASE_DAYS[term_index]
+
+ # 소한(22), 대한(23)은 1월이지만 기준 년도에서 검색
+ search_year = year if term_index < 22 else year
+
+ try:
+ start_day = max(1, base_day - 5)
+ start_dt = datetime(search_year, base_month, start_day)
+ except ValueError:
+ start_dt = datetime(search_year, base_month, 1)
+
+ # 20일 범위에서 절기 날짜 탐색
+ prev_diff = None
+ for i in range(20):
+ check_dt = start_dt + timedelta(days=i)
+ lon = _get_solar_longitude(check_dt)
+
+ # 황경 차이 계산 (0° 교차 처리)
+ diff = (lon - target_angle + 360) % 360
+ if diff > 180:
+ diff -= 360
+
+ if abs(diff) < 2.0:
+ return check_dt.date()
+
+ # 부호가 바뀌면 직전 날짜가 절기
+ if prev_diff is not None and prev_diff * diff < 0:
+ return (check_dt - timedelta(days=1)).date()
+
+ prev_diff = diff
+
+ return None
+
+
+def get_solar_term_date(year: int, term_index: int) -> date:
+ """특정 년도의 특정 절기 날짜 반환"""
+ try:
+ result = _get_solar_term_date_ephem(year, term_index)
+ if result:
+ return result
+ except Exception:
+ pass
+
+ # 폴백: 근사값 사용
+ base_month = SOLAR_TERM_BASE_MONTHS[term_index]
+ base_day = SOLAR_TERM_BASE_DAYS[term_index]
+ try:
+ return date(year, base_month, base_day)
+ except ValueError:
+ return date(year, base_month, min(28, base_day))
+
+
+def get_current_solar_term(year: int, month: int, day: int) -> int:
+ """주어진 날짜가 어느 절기 이후인지 반환 (0~23)"""
+ target = date(year, month, day)
+
+ # 역순으로 확인 (가장 최근 절기 찾기)
+ for i in range(23, -1, -1):
+ term_date = get_solar_term_date(year, i)
+
+ # 소한, 대한의 경우 년도 조정
+ if i >= 22:
+ if month >= 2:
+ term_date = date(year, term_date.month, term_date.day)
+ else:
+ term_date = date(year - 1, term_date.month, term_date.day)
+
+ if target >= term_date:
+ return i
+
+ return 23 # 입춘 이전 → 전년도 대한 이후
+
+
+def get_solar_term_month_branch(year: int, month: int, day: int) -> int:
+ """절기 기준 월주 지지 인덱스 계산 (0=자, 1=축, 2=인, ...)"""
+ term_index = get_current_solar_term(year, month, day)
+ return TERM_TO_MONTH_BRANCH[term_index]
+
+
+def get_days_to_next_solar_term(year: int, month: int, day: int) -> int:
+ """다음 절기까지 남은 일수 계산"""
+ current_term = get_current_solar_term(year, month, day)
+ next_term_index = (current_term + 1) % 24
+
+ next_year = year + 1 if current_term == 23 else year
+ next_term_date = get_solar_term_date(next_year, next_term_index)
+
+ current_date = date(year, month, day)
+ diff = (next_term_date - current_date).days
+ return max(1, diff)
+
+
+def get_days_from_prev_solar_term(year: int, month: int, day: int) -> int:
+ """이전 절기부터 주어진 날짜까지의 일수 계산"""
+ current_term = get_current_solar_term(year, month, day)
+ term_date = get_solar_term_date(year, current_term)
+
+ # 소한, 대한 년도 조정
+ if current_term >= 22:
+ if month >= 2:
+ term_date = date(year, term_date.month, term_date.day)
+ else:
+ term_date = date(year - 1, term_date.month, term_date.day)
+
+ target = date(year, month, day)
+ diff = (target - term_date).days
+ return max(1, diff)
diff --git a/saju-engine/docker-compose.yml b/saju-engine/docker-compose.yml
new file mode 100644
index 0000000..c29e7b9
--- /dev/null
+++ b/saju-engine/docker-compose.yml
@@ -0,0 +1,23 @@
+version: '3.9'
+
+services:
+ saju-engine:
+ build: .
+ container_name: saju-engine
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ - API_SECRET=${API_SECRET}
+ - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
+ - LOG_LEVEL=${LOG_LEVEL:-INFO}
+ - ENV=${ENV:-production}
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 15s
+ # NAS에서 메모리 제한 권장 (선택)
+ # mem_limit: 512m
+ # cpus: '1.0'
diff --git a/saju-engine/main.py b/saju-engine/main.py
new file mode 100644
index 0000000..a137918
--- /dev/null
+++ b/saju-engine/main.py
@@ -0,0 +1,274 @@
+"""
+사주 계산 엔진 API
+FastAPI + ephem 기반 사주팔자 계산 서비스
+
+환경변수:
+ API_SECRET: X-API-Secret 헤더 검증용 시크릿
+ ALLOWED_ORIGINS: CORS 허용 오리진 (쉼표 구분, 기본값: *)
+ LOG_LEVEL: 로그 레벨 (기본값: INFO)
+"""
+
+import os
+import logging
+from datetime import datetime
+from typing import Optional
+
+from fastapi import FastAPI, HTTPException, Request, Depends
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from slowapi import Limiter, _rate_limit_exceeded_handler
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from pydantic import BaseModel, Field, field_validator
+from dotenv import load_dotenv
+
+from calculator.saju_calculator import (
+ calculate_saju,
+ analyze_branch_interactions,
+ calculate_shinsal,
+ calculate_gongmang,
+ get_all_hidden_stems,
+ HEAVENLY_STEMS,
+ EARTHLY_BRANCHES,
+)
+from calculator.daeun_calculator import calculate_daeun, get_current_daeun
+from calculator.lotto_generator import generate_lotto_numbers, generate_multiple_sets
+
+load_dotenv()
+
+# ============================================================
+# 설정
+# ============================================================
+
+API_SECRET = os.getenv('API_SECRET', '')
+ALLOWED_ORIGINS = os.getenv('ALLOWED_ORIGINS', '*').split(',')
+LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
+
+logging.basicConfig(
+ level=getattr(logging, LOG_LEVEL, logging.INFO),
+ format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
+)
+logger = logging.getLogger('saju-engine')
+
+# ============================================================
+# Rate Limiter
+# ============================================================
+
+limiter = Limiter(key_func=get_remote_address)
+
+# ============================================================
+# FastAPI 앱
+# ============================================================
+
+app = FastAPI(
+ title='사주 계산 엔진',
+ description='NAS Docker 기반 사주팔자 계산 API',
+ version='1.0.0',
+ docs_url='/docs' if os.getenv('ENV', 'development') == 'development' else None,
+ redoc_url=None,
+)
+
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=ALLOWED_ORIGINS,
+ allow_credentials=True,
+ allow_methods=['GET', 'POST'],
+ allow_headers=['Content-Type', 'X-API-Secret'],
+)
+
+# ============================================================
+# 인증 의존성
+# ============================================================
+
+def verify_secret(request: Request):
+ if not API_SECRET:
+ return # 시크릿 미설정 시 스킵 (개발 환경)
+ secret = request.headers.get('X-API-Secret', '')
+ if secret != API_SECRET:
+ logger.warning(f'Unauthorized request from {request.client.host if request.client else "unknown"}')
+ raise HTTPException(status_code=401, detail='Unauthorized')
+
+
+# ============================================================
+# 요청/응답 스키마
+# ============================================================
+
+class SajuRequest(BaseModel):
+ year: int = Field(..., ge=1900, le=2100, description='생년')
+ month: int = Field(..., ge=1, le=12, description='생월')
+ day: int = Field(..., ge=1, le=31, description='생일')
+ hour: Optional[int] = Field(None, ge=0, le=23, description='생시 (없으면 null)')
+ gender: str = Field(..., pattern='^(male|female)$', description='성별')
+ calendar_type: str = Field('solar', pattern='^(solar|lunar)$', description='양력/음력')
+
+ @field_validator('year')
+ @classmethod
+ def validate_year(cls, v: int) -> int:
+ if v < 1900 or v > 2100:
+ raise ValueError('년도는 1900~2100 범위여야 합니다')
+ return v
+
+
+class LottoRequest(BaseModel):
+ year: int = Field(..., ge=1900, le=2100)
+ month: int = Field(..., ge=1, le=12)
+ day: int = Field(..., ge=1, le=31)
+ hour: Optional[int] = Field(None, ge=0, le=23)
+ gender: str = Field(..., pattern='^(male|female)$')
+ sets: int = Field(5, ge=1, le=10, description='생성할 번호 세트 수')
+
+
+# ============================================================
+# 헬스체크
+# ============================================================
+
+@app.get('/health')
+async def health_check():
+ return {'status': 'ok', 'timestamp': datetime.utcnow().isoformat()}
+
+
+# ============================================================
+# 사주 계산 엔드포인트
+# ============================================================
+
+@app.post('/saju/calculate', dependencies=[Depends(verify_secret)])
+@limiter.limit('30/minute')
+async def calculate_saju_api(request: Request, body: SajuRequest):
+ """
+ 사주팔자 전체 계산
+ - 사주팔자 (천간/지지/오행/십성/십이운성)
+ - 대운 (8개)
+ - 현재 대운
+ - 지지 상호작용 (합/충/형/파/해)
+ - 신살
+ - 공망
+ - 지장간
+ """
+ try:
+ logger.info(f'사주 계산 요청: {body.year}/{body.month}/{body.day} {body.gender}')
+
+ # 음력 변환 (필요 시)
+ year, month, day = body.year, body.month, body.day
+ if body.calendar_type == 'lunar':
+ try:
+ import korean_lunar_calendar
+ calendar = korean_lunar_calendar.KoreanLunarCalendar()
+ calendar.setLunarDate(year, month, day, False)
+ solar = calendar.SolarIsoFormat().split('-')
+ year, month, day = int(solar[0]), int(solar[1]), int(solar[2])
+ except Exception as e:
+ logger.warning(f'음력 변환 실패, 양력으로 처리: {e}')
+
+ # 사주팔자 계산
+ saju = calculate_saju(year, month, day, body.hour, body.gender)
+
+ # 대운 계산
+ daeun_list = calculate_daeun(
+ year, month, day,
+ body.gender,
+ saju['month']['stem'],
+ saju['month']['branch'],
+ )
+
+ # 현재 대운
+ current_year = datetime.now().year
+ current_daeun = get_current_daeun(daeun_list, current_year)
+
+ # 지지 상호작용
+ interactions = analyze_branch_interactions(saju)
+
+ # 신살
+ shinsal = calculate_shinsal(saju)
+
+ # 공망
+ gongmang = calculate_gongmang(saju['dayStem'], saju['day']['branch'])
+
+ # 지장간
+ hidden_stems = get_all_hidden_stems(saju)
+
+ return {
+ 'saju': saju,
+ 'daeunList': daeun_list,
+ 'currentDaeun': current_daeun,
+ 'interactions': interactions,
+ 'shinsal': shinsal,
+ 'gongmang': gongmang,
+ 'hiddenStems': hidden_stems,
+ }
+
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ logger.error(f'사주 계산 오류: {e}', exc_info=True)
+ raise HTTPException(status_code=500, detail='사주 계산 중 오류가 발생했습니다')
+
+
+# ============================================================
+# 로또 번호 생성 엔드포인트
+# ============================================================
+
+@app.post('/saju/lotto', dependencies=[Depends(verify_secret)])
+@limiter.limit('10/minute')
+async def generate_lotto_api(request: Request, body: LottoRequest):
+ """
+ 사주 기반 로또 번호 생성
+ - 오행 균형 반영
+ - 신살 보너스 반영
+ - 복수 세트 생성
+ """
+ try:
+ logger.info(f'로또 번호 생성 요청: {body.year}/{body.month}/{body.day} {body.gender}')
+
+ saju = calculate_saju(body.year, body.month, body.day, body.hour, body.gender)
+ shinsal = calculate_shinsal(saju)
+
+ # 단일 추천 번호
+ main_numbers = generate_lotto_numbers(saju, shinsal)
+
+ # 복수 세트
+ multiple_sets = generate_multiple_sets(saju, shinsal, sets=body.sets)
+
+ return {
+ 'main': main_numbers,
+ 'sets': multiple_sets,
+ 'dayStem': saju['dayStem'],
+ 'dayBranch': saju['day']['branch'],
+ }
+
+ except Exception as e:
+ logger.error(f'로또 번호 생성 오류: {e}', exc_info=True)
+ raise HTTPException(status_code=500, detail='로또 번호 생성 중 오류가 발생했습니다')
+
+
+# ============================================================
+# 절기 정보 엔드포인트
+# ============================================================
+
+@app.get('/solar-terms/{year}', dependencies=[Depends(verify_secret)])
+@limiter.limit('20/minute')
+async def get_solar_terms_api(request: Request, year: int):
+ """특정 년도의 24절기 날짜 목록 반환"""
+ if year < 1900 or year > 2100:
+ raise HTTPException(status_code=400, detail='년도는 1900~2100 범위여야 합니다')
+
+ from calculator.solar_terms import get_solar_term_date, SOLAR_TERMS
+
+ terms = []
+ for i, name in enumerate(SOLAR_TERMS):
+ d = get_solar_term_date(year, i)
+ terms.append({
+ 'index': i,
+ 'name': name,
+ 'date': d.isoformat(),
+ })
+
+ return {'year': year, 'terms': terms}
+
+
+if __name__ == '__main__':
+ import uvicorn
+ port = int(os.getenv('PORT', '8000'))
+ uvicorn.run('main:app', host='0.0.0.0', port=port, reload=False)
diff --git a/saju-engine/requirements.txt b/saju-engine/requirements.txt
new file mode 100644
index 0000000..04c8cf8
--- /dev/null
+++ b/saju-engine/requirements.txt
@@ -0,0 +1,6 @@
+fastapi==0.115.5
+uvicorn[standard]==0.32.1
+ephem==4.1.6
+slowapi==0.1.9
+python-dotenv==1.0.1
+pydantic==2.10.3
diff --git a/supabase/schema.sql b/supabase/schema.sql
new file mode 100644
index 0000000..a142a46
--- /dev/null
+++ b/supabase/schema.sql
@@ -0,0 +1,121 @@
+-- ============================================================
+-- 쟁승메이드 Supabase 스키마
+-- Supabase SQL Editor에서 순서대로 실행하세요
+-- ============================================================
+
+-- ① profiles (유저 프로필 - auth.users와 연결)
+create table public.profiles (
+ id uuid references auth.users(id) on delete cascade primary key,
+ email text,
+ full_name text,
+ avatar_url text,
+ created_at timestamptz default now(),
+ updated_at timestamptz default now()
+);
+
+-- 신규 가입 시 profiles 자동 생성 트리거
+create or replace function public.handle_new_user()
+returns trigger as $$
+begin
+ insert into public.profiles (id, email, full_name, avatar_url)
+ values (
+ new.id,
+ new.email,
+ new.raw_user_meta_data->>'full_name',
+ new.raw_user_meta_data->>'avatar_url'
+ );
+ return new;
+end;
+$$ language plpgsql security definer;
+
+create or replace trigger on_auth_user_created
+ after insert on auth.users
+ for each row execute procedure public.handle_new_user();
+
+-- RLS
+alter table public.profiles enable row level security;
+create policy "본인 프로필 조회" on public.profiles for select using (auth.uid() = id);
+create policy "본인 프로필 수정" on public.profiles for update using (auth.uid() = id);
+
+
+-- ② saju_records (사주 분석 결과 저장)
+create table public.saju_records (
+ id bigint generated by default as identity primary key,
+ user_id uuid references public.profiles(id) on delete cascade,
+ saju_data jsonb not null, -- SajuData JSON
+ interpretation text, -- AI 해석 Markdown
+ is_paid boolean default false, -- 유료 결제 여부
+ created_at timestamptz default now()
+);
+
+alter table public.saju_records enable row level security;
+create policy "본인 사주 기록 조회" on public.saju_records for select using (auth.uid() = user_id);
+create policy "본인 사주 기록 생성" on public.saju_records for insert with check (auth.uid() = user_id);
+
+
+-- ③ products (판매 상품 정의)
+create table public.products (
+ id text primary key, -- 예: 'saju_basic', 'saju_detail'
+ name text not null, -- 상품명
+ description text,
+ price integer not null, -- 원 단위
+ category text not null, -- 'saju' | 'lotto' | 'subscription'
+ is_active boolean default true,
+ created_at timestamptz default now()
+);
+
+-- 초기 상품 데이터
+insert into public.products (id, name, description, price, category) values
+ ('saju_detail', 'AI 사주 상세 리포트', '신강/신약, 용신, 대운, AI 12가지 항목 해석', 4900, 'saju'),
+ ('lotto_premium', '로또 프리미엄 구독', '매주 프리미엄 번호 5조합 + 통계', 4900, 'lotto');
+
+
+-- ④ orders (주문 - 결제 전 생성)
+create table public.orders (
+ id uuid default gen_random_uuid() primary key,
+ user_id uuid references public.profiles(id) on delete set null,
+ product_id text references public.products(id),
+ amount integer not null,
+ status text default 'pending', -- 'pending' | 'paid' | 'failed' | 'cancelled'
+ metadata jsonb, -- 추가 정보 (saju_record_id 등)
+ created_at timestamptz default now(),
+ updated_at timestamptz default now()
+);
+
+alter table public.orders enable row level security;
+create policy "본인 주문 조회" on public.orders for select using (auth.uid() = user_id);
+create policy "본인 주문 생성" on public.orders for insert with check (auth.uid() = user_id);
+
+
+-- ⑤ payments (결제 완료 내역 - 서버에서 검증 후 저장)
+create table public.payments (
+ id uuid default gen_random_uuid() primary key,
+ user_id uuid references public.profiles(id) on delete set null,
+ order_id uuid references public.orders(id),
+ product_name text not null,
+ amount integer not null,
+ status text default 'paid', -- 'paid' | 'refunded' | 'partial_refund'
+ pg_provider text, -- 'toss' | 'portone'
+ pg_payment_key text unique, -- PG사 결제 키
+ created_at timestamptz default now()
+);
+
+alter table public.payments enable row level security;
+create policy "본인 결제 내역 조회" on public.payments for select using (auth.uid() = user_id);
+
+
+-- ⑥ contact_requests (외주/서비스 문의 내역)
+create table public.contact_requests (
+ id uuid default gen_random_uuid() primary key,
+ user_id uuid references public.profiles(id) on delete set null,
+ email text not null,
+ name text,
+ service text not null, -- 문의 서비스 종류
+ message text not null,
+ status text default 'pending', -- 'pending' | 'in_progress' | 'completed'
+ created_at timestamptz default now()
+);
+
+alter table public.contact_requests enable row level security;
+create policy "본인 의뢰 내역 조회" on public.contact_requests for select using (auth.uid() = user_id);
+create policy "누구나 의뢰 생성" on public.contact_requests for insert with check (true);
diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts
new file mode 100644
index 0000000..b8b2d6e
--- /dev/null
+++ b/utils/supabase/middleware.ts
@@ -0,0 +1,35 @@
+import { NextResponse, type NextRequest } from 'next/server';
+import { createServerClient, type CookieMethodsServer } from '@supabase/ssr';
+
+export async function updateSession(request: NextRequest) {
+ // Supabase 환경변수가 없으면 그냥 통과
+ if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
+ return NextResponse.next({ request });
+ }
+
+ let supabaseResponse = NextResponse.next({ request });
+
+ const cookieMethods: CookieMethodsServer = {
+ getAll() {
+ return request.cookies.getAll();
+ },
+ setAll(cookiesToSet) {
+ cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
+ supabaseResponse = NextResponse.next({ request });
+ cookiesToSet.forEach(({ name, value, options }) =>
+ supabaseResponse.cookies.set(name, value, options)
+ );
+ },
+ };
+
+ const supabase = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
+ { cookies: cookieMethods }
+ );
+
+ // 세션 갱신 (IMPORTANT: getUser()는 반드시 호출해야 함)
+ await supabase.auth.getUser();
+
+ return supabaseResponse;
+}