사주 기능 이식 & 로그인, 유저 페이지 Supabase 연동 & 토스 페이먼츠 결제 연동 & 사주 심층 분석을 위한 기능 분리

This commit is contained in:
2026-03-10 04:28:56 +09:00
parent e8076b2b7a
commit 83043a357b
45 changed files with 8058 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="dashboard-layout">

View File

@@ -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 (
<button
onClick={handlePayment}
disabled={loading}
className={className}
>
{loading ? '결제 처리 중...' : children}
</button>
);
}

View File

@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
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<string | null>(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) {
<span className="text-slate-500 text-xs font-semibold uppercase tracking-wider"></span>
</div>
{navItems.map((item) => {
const isActive = pathname === item.href;
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
@@ -157,18 +192,59 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
})}
</nav>
{/* Bottom: Developer profile */}
{/* Bottom: 로그인 상태 */}
<div className="p-4 border-t border-[#1a3a7a]/50 flex-shrink-0">
<div className="flex items-center gap-3 px-1">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-sm font-bold flex-shrink-0 shadow">
{userEmail ? (
/* 로그인 상태 */
<div className="space-y-2">
<Link
href="/mypage"
onClick={onClose}
className={`flex items-center gap-3 px-3 py-2 rounded-xl transition-all ${
pathname.startsWith('/mypage')
? 'bg-gradient-to-r from-blue-600 to-violet-600'
: 'hover:bg-[#0a1f5c]'
}`}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-sm font-bold flex-shrink-0 shadow">
{userEmail[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-semibold truncate">{userEmail}</div>
<div className="text-blue-400 text-xs"></div>
</div>
<div className="w-2 h-2 rounded-full bg-emerald-400 flex-shrink-0" />
</Link>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-[#0a1f5c] text-xs transition-all"
>
</button>
</div>
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-semibold"></div>
<div className="text-slate-500 text-xs"> </div>
) : (
/* 비로그인 상태 */
<div className="space-y-2">
<div className="flex items-center gap-3 px-1 mb-2">
<div className="w-9 h-9 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-500 flex-shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-slate-400 text-sm font-medium"></div>
<div className="text-slate-600 text-xs"> </div>
</div>
</div>
<Link
href="/login"
onClick={onClose}
className="block w-full text-center bg-gradient-to-r from-blue-600 to-violet-600 text-white text-sm font-semibold px-3 py-2 rounded-xl hover:opacity-90 transition-all"
>
/
</Link>
</div>
<div className="w-2 h-2 rounded-full bg-emerald-400 flex-shrink-0" title="온라인" />
</div>
)}
</div>
</aside>
</>

213
app/login/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-[#04102b] flex items-center justify-center p-4">
{/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 opacity-[0.03]"
style={{ backgroundImage: 'linear-gradient(#4f8ef7 1px, transparent 1px), linear-gradient(90deg, #4f8ef7 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="absolute top-0 right-0 w-96 h-96 rounded-full bg-blue-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="absolute bottom-0 left-0 w-80 h-80 rounded-full bg-violet-500/10 blur-3xl translate-y-1/2 -translate-x-1/4" />
</div>
<div className="relative w-full max-w-md">
{/* 로고 */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xl shadow-lg shadow-blue-500/25">
</div>
<div className="text-left">
<div className="text-white font-bold text-xl leading-tight"></div>
<div className="text-blue-400 text-xs font-medium">Premium Dev Services</div>
</div>
</Link>
</div>
{/* 카드 */}
<div className="bg-white/5 border border-white/10 backdrop-blur rounded-2xl p-8 shadow-2xl">
<div className="text-center mb-7">
<h1 className="text-2xl font-extrabold text-white mb-1">
{isSignUp ? '회원가입' : '로그인'}
</h1>
<p className="text-blue-300/60 text-sm">
{isSignUp
? '가입 후 사주 기록, 결제 내역을 관리하세요'
: '사주 기록·결제·의뢰 내역을 확인하세요'}
</p>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm font-medium ${
message.includes('완료') || message.includes('확인해주세요')
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-300'
: 'bg-red-500/10 border border-red-500/30 text-red-300'
}`}>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4 mb-5">
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="password"
placeholder="6자 이상"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-500 hover:to-violet-500 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
</button>
</form>
{/* 전환 링크 */}
<div className="text-center mb-5">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm text-blue-400 hover:text-blue-300 transition"
>
{isSignUp ? '이미 계정이 있으신가요? 로그인 →' : '아직 계정이 없으신가요? 회원가입 →'}
</button>
</div>
{/* 구분선 */}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-transparent text-slate-500 text-xs"> </span>
</div>
</div>
{/* 구글 로그인 */}
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition text-white font-medium text-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google로
</button>
</div>
{/* 홈으로 */}
<div className="text-center mt-6">
<Link href="/" className="text-slate-500 hover:text-slate-300 text-sm transition">
</Link>
</div>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#04102b] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
);
}

390
app/mypage/page.tsx Normal file
View File

@@ -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<User | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('profile');
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
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 (
<div className="min-h-full flex items-center justify-center bg-[#f0f5ff]">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
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 (
<div className="min-h-full bg-[#f0f5ff]">
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-xl font-bold shadow-lg flex-shrink-0">
{user.email?.[0].toUpperCase()}
</div>
<div>
<div className="text-white font-bold text-lg leading-tight">{user.email}</div>
<div className="text-blue-300/60 text-sm mt-0.5">
: {new Date(user.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
<div className="ml-auto">
<button
onClick={handleLogout}
className="px-4 py-2 bg-white/5 border border-white/10 text-slate-300 text-sm rounded-xl hover:bg-white/10 transition"
>
</button>
</div>
</div>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
{/* 탭 */}
<div className="flex gap-1 bg-white border border-[#dbe8ff] rounded-xl p-1 mb-6">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
tab === t.key
? 'bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-bold ${
tab === t.key ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-600'
}`}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
{/* 내 정보 */}
{tab === 'profile' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-600 to-violet-600 rounded-full" />
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-[#04102b]">{user.email}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"> </span>
<span className="text-sm font-semibold text-[#04102b] capitalize">
{user.app_metadata?.provider === 'google' ? 'Google' : '이메일'}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-[#04102b]">
{new Date(user.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-600 to-violet-600 rounded-full" />
</h2>
<div className="grid grid-cols-2 gap-3">
<Link href="/saju/input" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
</div>
</div>
</div>
)}
{/* 사주 기록 */}
{tab === 'saju' && (
<div>
{sajuRecords.length === 0 ? (
<EmptyState
icon="✨"
title="저장된 사주 기록이 없습니다"
desc="사주 분석 후 결과를 저장하면 여기서 다시 확인할 수 있습니다"
linkHref="/saju/input"
linkLabel="사주 분석 시작"
/>
) : (
<div className="grid md:grid-cols-2 gap-4">
{sajuRecords.map((rec) => (
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-xs text-slate-400 mb-1">{new Date(rec.created_at).toLocaleDateString('ko-KR')}</div>
<div className="font-bold text-[#04102b]">
{rec.saju_data?.birth_year ?? '?'}{' '}
{rec.saju_data?.birth_month ?? '?'}{' '}
{rec.saju_data?.birth_day ?? '?'}
</div>
<div className="text-sm text-slate-500 mt-0.5">
{rec.saju_data?.gender === 'male' ? '남성' : '여성'}
{rec.saju_data?.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
</div>
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${rec.is_paid ? 'bg-amber-50 text-amber-600 border border-amber-200' : 'bg-slate-100 text-slate-500'}`}>
{rec.is_paid ? '유료' : '무료'}
</span>
</div>
{rec.interpretation && (
<p className="text-xs text-slate-500 line-clamp-2 bg-slate-50 rounded-lg px-3 py-2 mb-3">
{rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
</p>
)}
<Link
href={buildSajuResultUrl(rec)}
className="block w-full text-center py-2 rounded-xl text-xs font-bold bg-gradient-to-r from-[#04102b] to-[#0a2060] text-white hover:from-[#0a1f5c] hover:to-[#1a3a7a] transition"
>
{rec.is_paid && rec.interpretation ? 'AI 해석 다시 보기 →' : '결과 보기 →'}
</Link>
</div>
))}
</div>
)}
</div>
)}
{/* 결제 내역 */}
{tab === 'payments' && (
<div>
{payments.length === 0 ? (
<EmptyState
icon="💳"
title="결제 내역이 없습니다"
desc="서비스 구매 후 결제 내역이 여기에 표시됩니다"
linkHref="/saju"
linkLabel="서비스 보기"
/>
) : (
<div className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f0f5ff] border-b border-[#dbe8ff]">
<tr>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
</tr>
</thead>
<tbody>
{payments.map((p, i) => (
<tr key={p.id} className={i % 2 === 0 ? '' : 'bg-slate-50/50'}>
<td className="px-5 py-3 font-medium text-[#04102b]">{p.product_name}</td>
<td className="px-5 py-3 text-[#04102b]">{p.amount?.toLocaleString()}</td>
<td className="px-5 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
p.status === 'paid' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'
}`}>
{p.status === 'paid' ? '결제완료' : p.status}
</span>
</td>
<td className="px-5 py-3 text-slate-500 text-xs">
{new Date(p.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* 의뢰 내역 */}
{tab === 'orders' && (
<div>
{orders.length === 0 ? (
<EmptyState
icon="📋"
title="의뢰 내역이 없습니다"
desc="외주 개발, 서비스 문의 내역이 여기에 표시됩니다"
linkHref="/freelance"
linkLabel="외주 의뢰하기"
/>
) : (
<div className="space-y-3">
{orders.map((o) => (
<div key={o.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<div className="flex items-start justify-between mb-2">
<div className="font-bold text-[#04102b]">{o.service}</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${
o.status === 'completed' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' :
o.status === 'in_progress' ? 'bg-blue-50 text-blue-600 border border-blue-200' :
'bg-slate-100 text-slate-500'
}`}>
{o.status === 'completed' ? '완료' : o.status === 'in_progress' ? '진행중' : '대기중'}
</span>
</div>
<p className="text-sm text-slate-600 line-clamp-2">{o.message}</p>
<div className="text-xs text-slate-400 mt-2">{new Date(o.created_at).toLocaleDateString('ko-KR')}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function EmptyState({
icon, title, desc, linkHref, linkLabel,
}: {
icon: string; title: string; desc: string; linkHref: string; linkLabel: string;
}) {
return (
<div className="text-center py-16 bg-white rounded-2xl border border-[#dbe8ff]">
<div className="text-5xl mb-4">{icon}</div>
<div className="font-bold text-[#04102b] text-lg mb-2">{title}</div>
<div className="text-slate-500 text-sm mb-6">{desc}</div>
<Link
href={linkHref}
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm hover:opacity-90 transition-all shadow-lg shadow-blue-600/20"
>
{linkLabel}
</Link>
</div>
);
}

View File

@@ -301,6 +301,47 @@ export default function Home() {
</div>
</div>
</Link>
{/* ─ AI 사주 분석 ─ */}
<Link href="/saju" className="service-card group bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden hover:border-[#7c3aed]/30 hover:shadow-xl hover:shadow-violet-100 md:col-span-2">
<div className="relative bg-gradient-to-br from-[#0d0a2e] via-[#1a0f5c] to-[#04102b] px-6 pt-7 pb-6 overflow-hidden">
<div className="absolute inset-0 opacity-[0.06]"
style={{ backgroundImage: 'radial-gradient(circle, #c4b5fd 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
<div className="absolute top-2 right-2 w-28 h-28 rounded-full bg-amber-400/10 blur-2xl" />
<span className="absolute top-4 right-4 bg-violet-600 text-white text-xs font-bold px-2 py-0.5 rounded-lg tracking-wide">NEW</span>
<div className="relative flex items-start gap-5">
<div className="w-11 h-11 rounded-xl bg-violet-400/15 border border-violet-400/25 flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<div>
<div className="text-amber-400/70 text-xs font-semibold mb-0.5 tracking-wide">AI SAJU ANALYTICS</div>
<h3 className="text-white text-xl font-extrabold">AI </h3>
<p className="text-violet-200/60 text-xs mt-1"> GPT-4o의 12 </p>
</div>
</div>
</div>
<div className="px-6 py-5">
<p className="text-slate-600 text-sm leading-relaxed mb-4"> / , ·, AI가 .</p>
<div className="space-y-2 mb-5">
{['전통 사주팔자 계산', 'AI 12가지 항목 해석', '무료 기본 · 유료 상세'].map(f => (
<div key={f} className="flex items-center gap-2 text-sm text-slate-700">
<div className="w-4 h-4 rounded-full bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0"><div className="w-1.5 h-1.5 rounded-full bg-violet-500" /></div>
{f}
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-100">
<div>
<span className="text-[#04102b] font-extrabold text-lg"> / 4,900</span>
<span className="ml-2 text-xs bg-violet-50 border border-violet-200 text-violet-700 px-2 py-0.5 rounded-full font-medium">1</span>
</div>
<span className="text-[#7c3aed] text-sm font-semibold flex items-center gap-1"> </span>
</div>
</div>
</Link>
</div>
{/* ─ Freelance CTA ─ */}

62
app/payment/fail/page.tsx Normal file
View File

@@ -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 (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-slate-100 border-2 border-slate-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="inline-block bg-slate-100 border border-slate-200 text-slate-600 text-xs font-bold px-3 py-1 rounded-full mb-4">
{code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2">
{code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
</h2>
<p className="text-slate-500 text-sm mb-8 max-w-xs mx-auto leading-relaxed">{message}</p>
<div className="flex justify-center gap-3 flex-wrap">
<button
onClick={() => window.history.back()}
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20"
>
</button>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentFailPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-[#04102b] to-[#0a1f5c] px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={<div className="py-20 text-center text-slate-400 text-sm"> ...</div>}>
<FailContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -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 (
<div className="text-center py-20 px-6">
<div className="w-12 h-12 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
);
}
if (status === 'error') {
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-red-50 border-2 border-red-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2"> </h2>
<p className="text-slate-500 text-sm mb-8">{errorMsg}</p>
<div className="flex justify-center gap-3">
<Link href="/mypage" className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-5 py-2.5 rounded-xl font-semibold text-sm hover:bg-slate-50 transition">
</Link>
<Link href="/" className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-5 py-2.5 rounded-xl font-semibold text-sm">
</Link>
</div>
</div>
);
}
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-emerald-50 border-2 border-emerald-400 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="inline-block bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full mb-4">
</div>
<h2 className="text-2xl font-extrabold text-[#04102b] mb-2"> !</h2>
{productName && (
<p className="text-slate-500 text-sm mb-1">{productName}</p>
)}
<p className="text-slate-400 text-sm mb-8">
.
</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
href="/mypage?tab=payments"
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20"
>
</Link>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-[#04102b] to-[#0a1f5c] px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={
<div className="py-20 text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
}>
<SuccessContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 생년월일 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-3 gap-3">
<input
type="number"
placeholder="년 (예: 1990)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1900"
max="2100"
value={year}
onChange={(e) => setYear(e.target.value)}
required
/>
<input
type="number"
placeholder="월 (1-12)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1"
max="12"
value={month}
onChange={(e) => setMonth(e.target.value)}
required
/>
<input
type="number"
placeholder="일 (1-31)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1"
max="31"
value={day}
onChange={(e) => setDay(e.target.value)}
required
/>
</div>
</div>
{/* 태어난 시간 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
()
</label>
<select
className="w-full px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
value={hour}
onChange={(e) => setHour(e.target.value)}
>
<option value=""> / </option>
<option value="0"> () 23:00 - 01:00</option>
<option value="1"> () 01:00 - 03:00</option>
<option value="3"> () 03:00 - 05:00</option>
<option value="5"> () 05:00 - 07:00</option>
<option value="7"> () 07:00 - 09:00</option>
<option value="9"> () 09:00 - 11:00</option>
<option value="11"> () 11:00 - 13:00</option>
<option value="13"> () 13:00 - 15:00</option>
<option value="15"> () 15:00 - 17:00</option>
<option value="17"> () 17:00 - 19:00</option>
<option value="19"> () 19:00 - 21:00</option>
<option value="21"> () 21:00 - 23:00</option>
</select>
</div>
{/* 양력/음력 선택 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCalendarType('solar')}
className={`px-6 py-3 rounded-xl font-bold transition ${
calendarType === 'solar'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
<button
type="button"
onClick={() => setCalendarType('lunar')}
className={`px-6 py-3 rounded-xl font-bold transition ${
calendarType === 'lunar'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
</div>
{calendarType === 'lunar' && (
<div className="mt-3">
<label className="flex items-center justify-center gap-2 text-sm text-slate-500 cursor-pointer">
<input
type="checkbox"
checked={isLeapMonth}
onChange={(e) => setIsLeapMonth(e.target.checked)}
className="w-4 h-4 text-[#1a56db] border-gray-300 rounded focus:ring-[#1a56db]"
/>
<span></span>
</label>
</div>
)}
</div>
{/* 성별 선택 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setGender('male')}
className={`px-6 py-3 rounded-xl font-bold transition ${
gender === 'male'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
<button
type="button"
onClick={() => setGender('female')}
className={`px-6 py-3 rounded-xl font-bold transition ${
gender === 'female'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
</div>
</div>
{/* 제출 버튼 */}
<button
type="submit"
className="w-full bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
>
</button>
<p className="text-sm text-slate-500 text-center">
* .
</p>
</form>
);
}

43
app/saju/input/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
import SajuForm from '../components/SajuForm';
export default function SajuInputPage() {
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-12">
<div className="absolute inset-0 opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '28px 28px' }} />
<div className="absolute right-0 top-0 w-72 h-72 rounded-full bg-violet-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="relative max-w-xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
AI ·
</div>
<h1 className="text-3xl md:text-4xl font-extrabold text-white leading-tight mb-3 tracking-tight">
</h1>
<p className="text-blue-200/60 text-sm leading-relaxed">
<br />
.
</p>
</div>
</div>
{/* Form 영역 */}
<div className="px-6 py-10 max-w-2xl mx-auto">
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 shadow-lg">
<div className="flex items-center gap-2 mb-6">
<div className="w-1 h-5 bg-gradient-to-b from-[#1a56db] to-[#7c3aed] rounded-full" />
<h2 className="font-bold text-[#04102b] text-base"> </h2>
</div>
<SajuForm />
</div>
<p className="text-center text-xs text-slate-400 mt-6">
.
</p>
</div>
</div>
);
}

341
app/saju/page.tsx Normal file
View File

@@ -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<SajuRecord[]>([]);
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 (
<div className="min-h-full bg-[#f0f5ff]">
{/* ─── Hero ─── */}
<div className="relative overflow-hidden bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-14 lg:px-12">
<div className="absolute inset-0 opacity-[0.06]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '30px 30px' }} />
<div className="absolute right-0 top-0 w-96 h-96 rounded-full bg-violet-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="absolute left-1/3 bottom-0 w-64 h-64 rounded-full bg-amber-400/8 blur-3xl translate-y-1/2" />
<div className="relative max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-5 tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
× AI ·
</div>
<h1 className="text-4xl md:text-5xl font-extrabold text-white leading-tight mb-5 tracking-tight">
AI가 <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#c4b5fd] to-[#fbbf24]">
</span>
</h1>
<p className="text-blue-200/70 text-base md:text-lg leading-relaxed mb-8 max-w-xl mx-auto">
AI .<br />
12 .
</p>
{/* 이전 기록 있으면 분기 버튼, 없으면 단일 CTA */}
{authChecked && hasPaid ? (
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</Link>
<a
href="#past-records"
className="inline-flex items-center gap-2 bg-white/10 border border-white/20 text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all hover:bg-white/20"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
) : (
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</Link>
)}
</div>
</div>
<div className="px-6 py-12 lg:px-12">
<div className="max-w-4xl mx-auto space-y-10">
{/* ─── 이전 기록 섹션 (구매한 유저만) ─── */}
{hasPaid && paidRecords.length > 0 && (
<div id="past-records">
<div className="text-center mb-6">
<p className="text-violet-600 text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
<h2 className="text-2xl font-extrabold text-[#04102b]"> AI </h2>
<p className="text-slate-500 text-sm mt-1"> </p>
</div>
<div className="grid md:grid-cols-2 gap-4">
{paidRecords.map((rec) => (
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5 hover:border-violet-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-xs text-slate-400 mb-1">
{new Date(rec.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="font-bold text-[#04102b] text-base">
{rec.saju_data.birth_year ?? '?'}{' '}
{rec.saju_data.birth_month ?? '?'}{' '}
{rec.saju_data.birth_day ?? '?'}
</div>
<div className="text-sm text-slate-500 mt-0.5">
{rec.saju_data.gender === 'male' ? '남성' : '여성'}
{rec.saju_data.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
</div>
</div>
<span className="text-xs font-bold px-2 py-1 rounded-lg bg-amber-50 text-amber-600 border border-amber-200 flex-shrink-0">
AI
</span>
</div>
{rec.interpretation && (
<p className="text-xs text-slate-500 bg-slate-50 rounded-lg px-3 py-2 mb-3 line-clamp-2">
{rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
</p>
)}
<Link
href={buildResultUrl(rec)}
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-gradient-to-r from-[#04102b] to-[#0a2060] text-white hover:from-[#0a1f5c] hover:to-[#1a3a7a] transition"
>
</Link>
</div>
))}
</div>
</div>
)}
{/* ─── 바로 시작하기 CTA ─── */}
<div className="bg-gradient-to-r from-[#04102b] via-[#0a1f5c] to-[#0d2d8a] rounded-2xl border border-[#1a3a7a] p-8 text-center relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.04]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '25px 25px' }} />
<div className="relative">
<div className="text-3xl mb-3"></div>
<h3 className="text-2xl font-extrabold text-white mb-2"> </h3>
<p className="text-blue-200/60 text-sm mb-6"> , </p>
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] text-white px-8 py-3.5 rounded-xl font-semibold text-base hover:from-[#1e4fc2] hover:to-[#6d28d9] transition-all shadow-lg shadow-violet-900/40"
>
</Link>
</div>
</div>
{/* ─── 무료 vs 유료 비교표 ─── */}
<div>
<div className="text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b] tracking-tight"> vs </h2>
<p className="text-slate-500 text-sm mt-2"> , AI 4,900</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* 무료 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6 shadow-sm">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center">
<svg className="w-5 h-5 text-[#1a56db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wide">FREE</div>
<div className="text-lg font-extrabold text-[#04102b]"> </div>
</div>
</div>
<ul className="space-y-3">
{[
'사주팔자 원국 (년·월·일·시주)',
'천간·지지·지장간 표',
'십성 및 십이운성',
'오행 분포 차트',
'지지 상호작용 (합·충·형)',
'일간 분석 요약',
].map((item) => (
<li key={item} className="flex items-center gap-2.5 text-sm text-slate-700">
<div className="w-4 h-4 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-[#1a56db]" />
</div>
{item}
</li>
))}
</ul>
<div className="mt-6 pt-5 border-t border-slate-100">
<div className="text-2xl font-extrabold text-[#04102b]"></div>
<div className="text-xs text-slate-500 mt-1"> </div>
<Link
href="/saju/input"
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] hover:bg-blue-50 transition"
>
</Link>
</div>
</div>
{/* 유료 */}
<div className="bg-gradient-to-br from-[#04102b] to-[#0a2060] rounded-2xl border border-[#1a3a7a] p-6 shadow-lg relative overflow-hidden">
<div className="absolute top-4 right-4 bg-amber-400 text-[#04102b] text-xs font-bold px-2 py-0.5 rounded-lg">
4,900
</div>
<div className="absolute bottom-0 right-0 w-32 h-32 rounded-full bg-violet-500/10 blur-2xl" />
<div className="flex items-center gap-3 mb-5 relative">
<div className="w-10 h-10 rounded-xl bg-violet-500/20 border border-violet-400/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<div className="text-xs font-bold text-violet-300 uppercase tracking-wide">AI PREMIUM</div>
<div className="text-lg font-extrabold text-white">AI </div>
</div>
</div>
<ul className="space-y-3 relative">
{[
'무료 기본 분석 전체 포함',
'신강/신약 정밀 판단',
'용신·희신·기신 추정',
'대운 (10년 주기) 분석',
'올해 세운 흐름',
'GPT-4o AI 12가지 상세 해석',
].map((item) => (
<li key={item} className="flex items-center gap-2.5 text-sm text-blue-200">
<div className="w-4 h-4 rounded-full bg-amber-400/20 border border-amber-400/40 flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400" />
</div>
{item}
</li>
))}
</ul>
<div className="mt-6 pt-5 border-t border-white/10 relative">
<div className="text-2xl font-extrabold text-amber-400">4,900</div>
<div className="text-xs text-blue-300/70 mt-1 mb-4">1 · </div>
{hasPaid ? (
<Link
href="/saju/input"
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
>
</Link>
) : (
<PaymentButton
productId="saju_detail"
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
>
AI
</PaymentButton>
)}
</div>
</div>
</div>
</div>
{/* ─── FAQ ─── */}
<div>
<div className="text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
<h2 className="text-2xl font-extrabold text-[#04102b]"> </h2>
</div>
<div className="space-y-4">
{faqItems.map((item, i) => (
<div key={i} className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-[#1a56db] text-xs font-bold">Q</span>
</div>
<div>
<p className="font-bold text-[#04102b] text-sm mb-2">{item.q}</p>
<p className="text-slate-600 text-sm leading-relaxed">{item.a}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
<div className="relative">
<div className="inline-flex items-center gap-2 bg-amber-400/10 border border-amber-400/25 text-amber-300 text-xs font-semibold px-3 py-1 rounded-full mb-3">
AI PREMIUM
</div>
<h3 className="text-xl font-extrabold text-white mb-2">AI (12 )</h3>
<p className="text-blue-200/60 text-sm mb-5">
, , , , , <br />
GPT-4o가 .
</p>
<PaymentButton
productId="saju_detail"
returnUrl={currentUrl}
className="inline-flex items-center gap-2 bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all shadow-lg"
>
AI · 4,900
</PaymentButton>
</div>
</div>
);
}
// AI 생성 중
if (status === 'loading') {
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 text-center">
<div className="w-10 h-10 border-2 border-violet-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm font-medium">AI가 ...</p>
<p className="text-slate-400 text-xs mt-1"> 20~30 </p>
</div>
);
}
// 오류
if (status === 'error') {
return (
<div className="bg-white rounded-2xl border border-red-200 p-6 text-center">
<p className="text-red-500 text-sm font-medium mb-3">AI .</p>
<button
onClick={() => { called.current = false; setStatus('idle'); }}
className="text-xs text-blue-600 underline"
>
</button>
</div>
);
}
// AI 해석 완료
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-slate-100">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-amber-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h2 className="text-lg font-extrabold text-[#04102b]">AI </h2>
<span className="ml-auto text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 font-bold px-2 py-0.5 rounded-full">
</span>
</div>
<div className="prose prose-sm max-w-none text-slate-700 leading-relaxed whitespace-pre-wrap">
{interpretation}
</div>
</div>
);
}

584
app/saju/result/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center">
<div className="text-center py-20">
<p className="text-slate-500 text-sm mb-4"> . .</p>
<a href="/saju/input" className="text-blue-600 underline text-sm"> </a>
</div>
</div>
);
}
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<string, unknown> = { 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 (
<div className="min-h-full bg-[#f0f5ff]">
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
</div>
<h1 className="text-3xl font-extrabold text-white mb-2"> </h1>
<p className="text-blue-200/60 text-sm"> AI </p>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
<div className="grid lg:grid-cols-[280px_1fr] gap-6">
{/* 사이드바 - 기본 정보 */}
<aside className="lg:sticky lg:top-6 h-fit">
<div className="bg-[#04102b] rounded-2xl p-6 text-white">
<h2 className="text-base font-bold mb-5 text-center pb-4 border-b border-white/10">
</h2>
<div className="space-y-4 text-sm">
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">
{isLunar ? (
<div>
<div> {inputYear}.{inputMonth}.{inputDay}{isLeap ? ' (윤달)' : ''}</div>
<div className="text-xs text-blue-300/50 mt-0.5"> {yearNum}.{monthNum}.{dayNum}</div>
</div>
) : (
<div>{yearNum}.{monthNum}.{dayNum}</div>
)}
</div>
</div>
{hourNum !== null && (
<div>
<div className="text-blue-300/60 mb-1"> </div>
<div className="font-bold">{hourNum}</div>
</div>
)}
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{gender === 'male' ? '남성' : '여성'}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{zodiacAnimal}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold text-2xl text-amber-400">
{sajuData.day.stem} ({sajuData.day.stemKr})
</div>
<div className="text-xs text-blue-300/60 mt-1">
{FIVE_ELEMENTS_KR[sajuData.day.element as keyof typeof FIVE_ELEMENTS_KR]}({sajuData.day.element})
</div>
</div>
</div>
<div className="mt-5 pt-5 border-t border-white/10 space-y-2">
<Link
href="/saju/input"
className="block w-full text-center bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition text-sm font-medium"
>
</Link>
<Link
href="/saju"
className="block w-full text-center bg-violet-500/20 hover:bg-violet-500/30 text-violet-300 px-4 py-2 rounded-lg transition text-sm font-medium"
>
</Link>
</div>
</div>
</aside>
{/* 메인 콘텐츠 */}
<main className="space-y-6">
{/* 사주팔자 표 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-xl font-extrabold text-[#04102b] mb-5 text-center"> ()</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-[#04102b] text-white">
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
{sajuData.hour && <th className="py-2.5 px-3 text-center font-bold text-xs"></th>}
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
</tr>
</thead>
<tbody>
{/* 천간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.stemKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.stemKr}</div>
<div className="text-xs text-amber-600 font-bold mt-0.5"></div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.stemKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.stemKr}</div>
</td>
</tr>
{/* 지지 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.branchKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.branchKr}</div>
</td>
</tr>
{/* 지장간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">
<div></div>
<div className="text-[10px] text-slate-400 font-normal"> </div>
</td>
{(() => {
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) => (
<td key={idx} className={`py-2 px-2 text-center ${h?.pillar === '일주' ? 'bg-amber-50' : ''}`}>
{h && (
<div className="flex flex-wrap justify-center gap-1">
{h.stems.map((s, si) => (
<span
key={si}
className={`inline-block px-1.5 py-0.5 rounded text-xs font-semibold border ${elementBgColors[s.element] || 'bg-gray-100'}`}
title={s.role}
>
{s.stemKr}
</span>
))}
</div>
)}
</td>
));
})()}
</tr>
{/* 십성 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.tenGod}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.tenGod}</div>
</td>
</tr>
{/* 십이운성 */}
<tr>
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.fortune}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.fortune}</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* 지지 상호작용 */}
{analysis.branchInteractions.length > 0 && (
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-3 text-center"> </h3>
<div className="flex flex-wrap justify-center gap-2">
{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 (
<span key={idx} className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold border ${colorClass}`}>
{inter.type} {inter.branchesKr.join('')}
{inter.resultElement && `${FIVE_ELEMENTS_KR[inter.resultElement as keyof typeof FIVE_ELEMENTS_KR]}`}
</span>
);
})}
</div>
</div>
)}
{/* 오행 균형 */}
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-4 text-center"> </h3>
<div className="grid grid-cols-5 gap-2">
{Object.entries(elementScores).map(([element, score]) => (
<div key={element} className="text-center">
<div className={`text-lg font-bold mb-1 ${elementColors[element] || ''}`}>{element}</div>
<div className="text-xs text-slate-500 mb-2">
{FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR]}
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5 mb-1">
<div
className={`h-1.5 rounded-full transition-all ${element === sajuData.day.element
? 'bg-gradient-to-r from-[#1a56db] to-[#7c3aed]'
: 'bg-slate-400'
}`}
style={{ width: `${Math.max(score, 5)}%` }}
/>
</div>
<div className="text-xs font-bold text-[#04102b]">{score}%</div>
</div>
))}
</div>
</div>
</div>
{/* 분석 카드 그리드 */}
<div className="grid md:grid-cols-2 gap-6">
{/* 신강/신약 + 용신 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> </h3>
<div className="flex items-center gap-3 mb-4">
<span className={`inline-block px-4 py-1.5 rounded-xl text-sm font-bold ${
analysis.dayMasterStrength.result === '신강'
? 'bg-red-100 text-red-700 border-2 border-red-400'
: analysis.dayMasterStrength.result === '신약'
? 'bg-blue-100 text-blue-700 border-2 border-blue-400'
: 'bg-green-100 text-green-700 border-2 border-green-400'
}`}>
{analysis.dayMasterStrength.result}
</span>
<span className="text-slate-500 text-xs">: {analysis.dayMasterStrength.score}</span>
</div>
<ul className="space-y-1 text-xs text-slate-500 mb-5">
{analysis.dayMasterStrength.reasons.map((r, i) => (
<li key={i} className="flex items-start">
<span className="text-amber-500 mr-1.5">-</span>
<span>{r}</span>
</li>
))}
</ul>
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2.5 text-sm"> / / </h4>
<div className="flex flex-wrap gap-2 mb-3">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.yongShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.yongShinKr}
</span>
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.heeShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.heeShinKr}
</span>
<span className="px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 border border-slate-300 text-slate-700">
: {analysis.yongShin.giShinKr}
</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">{analysis.yongShin.explanation}</p>
</div>
</div>
{/* 신살 + 공망 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> ()</h3>
{analysis.shinsal.length > 0 ? (
<div className="space-y-2 mb-5">
{analysis.shinsal.map((s, i) => (
<div key={i} className="flex items-start gap-2 p-3 rounded-xl bg-[#f0f5ff]">
<span className="inline-block px-2 py-0.5 bg-[#04102b] text-white rounded-lg text-xs font-bold whitespace-nowrap">
{s.name}
</span>
<div>
<div className="text-xs font-semibold text-[#04102b]">
{s.pillar} {s.branchKr}
</div>
<div className="text-xs text-slate-500 mt-0.5">{s.description}</div>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-xs mb-5"> .</p>
)}
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm"> ()</h4>
<div className="flex gap-2 mb-2">
{analysis.gongmang.branchesKr.map((bk, i) => (
<span key={i} className="px-2.5 py-1 bg-[#04102b] text-white rounded-lg text-xs font-bold">
{bk}
</span>
))}
</div>
<p className="text-xs text-slate-500 leading-relaxed">{analysis.gongmang.description}</p>
</div>
{/* 세운 정보 */}
<div className="border-t border-slate-100 pt-4 mt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm">
{analysis.seun.year}
</h4>
<div className="flex items-center gap-2 mb-2">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.seun.element] || 'bg-gray-100'}`}>
{analysis.seun.stemKr}{analysis.seun.branchKr}
</span>
<span className="text-xs text-slate-500">{analysis.seun.elementKr} </span>
</div>
{analysis.seun.interactions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{analysis.seun.interactions.map((si, i) => (
<span key={i} className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
si.type.includes('합') ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
}`}>
{si.type} {si.branchesKr.join('')}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* 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 (
<SajuAISection
hasPaid={hasPaid}
savedInterpretation={savedInterpretation}
sajuData={sajuData}
daeun={currentDaeun}
daeunList={daeunList}
gender={gender}
birthKey={birthKey}
currentUrl={currentUrl}
/>
);
})()}
{/* 대운 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-lg font-extrabold text-[#04102b] mb-5 text-center">
() 10
</h2>
{currentDaeun && (
<div className="bg-gradient-to-r from-[#04102b] to-[#0a2060] rounded-2xl p-5 mb-5 text-white">
<h3 className="text-sm font-bold mb-3 text-center text-blue-300"> </h3>
<div className="text-center mb-3">
<div className="text-3xl font-bold mb-1">
{currentDaeun.stem}{currentDaeun.branch}
</div>
<div className="text-base text-blue-200">
{currentDaeun.stemKr}{currentDaeun.branchKr}
</div>
<div className="text-xs text-blue-300/70 mt-1">
{currentDaeun.age} ~ {currentDaeun.age + 9} ({currentDaeun.startYear} ~ {currentDaeun.endYear})
</div>
</div>
<p className="text-center leading-relaxed text-xs text-blue-200/80">
{getDaeunDescription(currentDaeun, sajuData.day.stem)}
</p>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{daeunList.map((daeun, index) => {
const isCurrent = currentDaeun &&
daeun.startYear === currentDaeun.startYear &&
daeun.endYear === currentDaeun.endYear;
return (
<div
key={index}
className={`rounded-xl p-3 border-2 transition ${isCurrent
? 'bg-amber-50 border-amber-400'
: 'bg-white border-[#dbe8ff]'
}`}
>
<div className="text-center">
<div className="text-xl font-bold text-[#04102b] mb-0.5">
{daeun.stem}{daeun.branch}
</div>
<div className="text-xs text-slate-500 mb-1.5">
{daeun.stemKr}{daeun.branchKr}
</div>
<div className="text-xs text-slate-400">
{daeun.age} ~ {daeun.age + 9}
</div>
<div className="text-xs text-slate-400">
{daeun.startYear} ~ {daeun.endYear}
</div>
{isCurrent && (
<div className="mt-1.5">
<span className="inline-block bg-[#04102b] text-white text-xs px-2.5 py-0.5 rounded-full font-semibold">
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -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() {
</li>
))}
</ul>
<button
onClick={() => openModal(`로또 번호 추천 - ${plan.name}`)}
<PaymentButton
productId={plan.productId}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-amber-400 text-[#04102b] hover:bg-amber-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</button>
</PaymentButton>
</div>
))}
</div>

View File

@@ -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() {
</li>
))}
</ul>
<button
onClick={() => openModal(`주식 자동 매매 - ${plan.name}`)}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-emerald-400 text-[#011225] hover:bg-emerald-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</button>
{plan.installProductId ? (
<PaymentButton
productId={plan.installProductId}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-emerald-400 text-[#011225] hover:bg-emerald-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</PaymentButton>
) : (
<button
onClick={() => openModal(`주식 자동 매매 - ${plan.name}`)}
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-[#04102b] text-white hover:bg-[#0a1f5c]"
>
</button>
)}
</div>
))}
</div>