사주 기능 이식 & 로그인, 유저 페이지 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

@@ -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>
</>