chore(phase0): PortOne 잔재 제거 — 계좌이체 단일 소스 확정, saju 결제 CTA 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,135 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/security';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ─────
|
||||
const ip = getClientIp(request);
|
||||
const rl = checkRateLimit(`payment:${ip}`, 60_000, 10);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { paymentId } = body;
|
||||
|
||||
// ── 기본 파라미터 검증 ────────────────────────────────────
|
||||
if (!paymentId || typeof paymentId !== 'string' || paymentId.length > 200) {
|
||||
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 로그인 사용자 확인 ────────────────────────────────────
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── DB에서 주문 확인 ──────────────────────────────────────
|
||||
const { data: order, error: orderFetchError } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', paymentId)
|
||||
.single();
|
||||
|
||||
if (orderFetchError || !order) {
|
||||
return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
|
||||
}
|
||||
if (order.user_id !== user.id) {
|
||||
return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 });
|
||||
}
|
||||
if (order.status === 'paid') {
|
||||
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 포트원 V2 결제 조회 API ───────────────────────────────
|
||||
const apiSecret = process.env.PORTONE_API_SECRET;
|
||||
if (!apiSecret) {
|
||||
console.error('[Payment] PORTONE_API_SECRET 미설정');
|
||||
return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
|
||||
}
|
||||
|
||||
const portoneRes = await fetch(
|
||||
`https://api.portone.io/payments/${encodeURIComponent(paymentId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `PortOne ${apiSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!portoneRes.ok) {
|
||||
const err = await portoneRes.json().catch(() => ({}));
|
||||
console.error(`[Payment] 포트원 조회 실패 paymentId=${paymentId} status=${portoneRes.status}`, err);
|
||||
return NextResponse.json(
|
||||
{ error: '결제 확인에 실패했습니다. 고객센터에 문의해주세요.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const paymentData = await portoneRes.json();
|
||||
|
||||
// ── 결제 상태 & 금액 검증 ─────────────────────────────────
|
||||
if (paymentData.status !== 'PAID') {
|
||||
console.warn(`[Payment] 미완료 결제 paymentId=${paymentId} status=${paymentData.status}`);
|
||||
return NextResponse.json(
|
||||
{ error: '결제가 완료되지 않았습니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 DB 금액과 포트원 결제 금액 비교 (위조 방어)
|
||||
const paidAmount = paymentData.amount?.total;
|
||||
if (paidAmount !== order.amount) {
|
||||
console.warn(`[Payment] 금액 불일치 paymentId=${paymentId} db=${order.amount} paid=${paidAmount} user=${user.id}`);
|
||||
return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── orders 상태 업데이트 ──────────────────────────────────
|
||||
const { error: updateError } = await supabase
|
||||
.from('orders')
|
||||
.update({ status: 'paid' })
|
||||
.eq('id', paymentId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('[Payment] Order update error:', updateError.message);
|
||||
return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── payments 레코드 생성 ──────────────────────────────────
|
||||
const pgPaymentId = paymentData.pgResponse?.pgTxId ?? paymentData.paymentId ?? paymentId;
|
||||
const { error: paymentError } = await supabase.from('payments').insert({
|
||||
user_id: order.user_id,
|
||||
order_id: paymentId,
|
||||
product_name: order.metadata?.product_name ?? order.product_id,
|
||||
amount: order.amount,
|
||||
status: 'paid',
|
||||
pg_provider: 'portone_kcp',
|
||||
pg_payment_key: pgPaymentId,
|
||||
});
|
||||
|
||||
if (paymentError) {
|
||||
console.error('[Payment] Payment insert error:', paymentError.message);
|
||||
return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
paymentId,
|
||||
orderName: paymentData.orderName,
|
||||
amount: paidAmount,
|
||||
status: paymentData.status,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('[Payment] Unexpected error:', error);
|
||||
return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { PRODUCTS } from '@/lib/products';
|
||||
import { getActiveChannels, type PaymentChannel } from '@/lib/payment-channels';
|
||||
import PortOne from '@portone/browser-sdk/v2';
|
||||
|
||||
interface PaymentButtonProps {
|
||||
productId: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export default function PaymentButton({ productId, className, style, children, returnUrl }: PaymentButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showMethodPicker, setShowMethodPicker] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
const product = PRODUCTS[productId];
|
||||
const channels = getActiveChannels();
|
||||
|
||||
const processPayment = async (channel: PaymentChannel) => {
|
||||
setShowMethodPicker(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
// 1. 로그인 확인
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/login?next=' + encodeURIComponent(window.location.pathname));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 프로필 없으면 생성
|
||||
await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
|
||||
|
||||
// 3. Supabase에 order 생성
|
||||
const paymentId = crypto.randomUUID();
|
||||
const { error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert({
|
||||
id: paymentId,
|
||||
user_id: user.id,
|
||||
product_id: productId,
|
||||
amount: product.price,
|
||||
status: 'pending',
|
||||
metadata: { product_name: product.name, pay_channel: channel.id },
|
||||
});
|
||||
|
||||
if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
|
||||
|
||||
// 4. 포트원 V2 결제 요청
|
||||
const response = await PortOne.requestPayment({
|
||||
storeId: process.env.NEXT_PUBLIC_PORTONE_STORE_ID ?? '',
|
||||
channelKey: channel.channelKey,
|
||||
paymentId,
|
||||
orderName: product.name,
|
||||
totalAmount: product.price,
|
||||
currency: 'CURRENCY_KRW',
|
||||
payMethod: channel.payMethod,
|
||||
customer: {
|
||||
email: user.email ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 결제 결과 처리
|
||||
if (!response || response.code != null) {
|
||||
if (response?.code === 'FAILURE_TYPE_PG' || response?.message?.includes('cancel')) {
|
||||
return;
|
||||
}
|
||||
throw new Error(response?.message ?? '결제 요청 실패');
|
||||
}
|
||||
|
||||
// 6. 서버에서 결제 검증
|
||||
const confirmRes = await fetch('/api/payment/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentId }),
|
||||
});
|
||||
|
||||
const confirmData = await confirmRes.json();
|
||||
|
||||
if (!confirmRes.ok || !confirmData.success) {
|
||||
throw new Error(confirmData.error || '결제 검증에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 7. 결제 성공
|
||||
if (returnUrl) {
|
||||
router.push(returnUrl);
|
||||
} else {
|
||||
router.push(`/payment/success?paymentId=${paymentId}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { code?: string; message?: string };
|
||||
if (error?.code === 'USER_CANCEL' || error?.message?.includes('cancel')) {
|
||||
return;
|
||||
}
|
||||
alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (channels.length === 0) {
|
||||
alert('결제 서비스가 준비 중입니다.');
|
||||
return;
|
||||
}
|
||||
// 채널이 1개면 바로 결제, 여러 개면 선택 UI
|
||||
if (channels.length === 1) {
|
||||
processPayment(channels[0]);
|
||||
} else {
|
||||
setShowMethodPicker(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const isTestMode = !process.env.NEXT_PUBLIC_PORTONE_STORE_ID
|
||||
|| process.env.NODE_ENV === 'development';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: style ? 'block' : 'inline-block', position: 'relative' }}>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{loading ? '결제 처리 중...' : children}
|
||||
</button>
|
||||
{isTestMode && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -8, right: -8,
|
||||
background: '#f59e0b', color: '#fff',
|
||||
fontSize: 9, fontWeight: 800, letterSpacing: '0.05em',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
pointerEvents: 'none', userSelect: 'none',
|
||||
}}>
|
||||
TEST
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 결제수단 선택 모달 */}
|
||||
{showMethodPicker && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setShowMethodPicker(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-[#04102b] px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-[#1a56db] flex items-center justify-center text-white font-bold text-[10px]">
|
||||
쟁
|
||||
</div>
|
||||
<span className="text-white font-bold text-sm">결제수단 선택</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodPicker(false)}
|
||||
className="text-white/60 hover:text-white transition text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="text-slate-500 text-xs mb-3">
|
||||
{product.name} · {product.price.toLocaleString()}원
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => processPayment(channel)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3.5 rounded-xl border border-slate-200 hover:border-[#1a56db] hover:bg-blue-50/50 transition text-left group"
|
||||
>
|
||||
<span className="text-xl">{channel.icon}</span>
|
||||
<span className="text-sm font-semibold text-slate-700 group-hover:text-[#1a56db]">
|
||||
{channel.label}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-slate-300 group-hover:text-[#1a56db] ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'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 === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{code === 'USER_CANCEL' || 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-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
|
||||
>
|
||||
다시 시도하기
|
||||
</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="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
|
||||
쟁
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
function SuccessContent() {
|
||||
const params = useSearchParams();
|
||||
const paymentId = params.get('paymentId');
|
||||
|
||||
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 mb-2" style={{ color: 'var(--jsm-ink)' }}>결제가 완료되었습니다!</h2>
|
||||
{paymentId && (
|
||||
<p className="text-slate-400 text-xs mb-1">주문번호: {paymentId}</p>
|
||||
)}
|
||||
<p className="text-slate-500 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-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
|
||||
>
|
||||
결제 내역 확인 →
|
||||
</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="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
|
||||
쟁
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
import { PRODUCTS } from '@/lib/products';
|
||||
|
||||
// DB products 테이블에 등록된 상품만 테스트 가능
|
||||
const TEST_PRODUCTS = [
|
||||
'saju_detail', // 1,000원
|
||||
];
|
||||
|
||||
export default function PaymentTestPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-extrabold text-[#04102b] mb-2">결제 테스트</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
포트원 V2 테스트 모드 — 실제 청구되지 않습니다.
|
||||
</p>
|
||||
<div className="mt-3 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-4 py-2.5 rounded-xl">
|
||||
이 페이지는 관리자 테스트 전용입니다. 배포 전 삭제하세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{TEST_PRODUCTS.map((id) => {
|
||||
const product = PRODUCTS[id];
|
||||
if (!product) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-slate-800">{product.name}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{product.price.toLocaleString()}원
|
||||
{product.type === 'monthly' && ' / 월'}
|
||||
<span className="ml-2 text-slate-300">({id})</span>
|
||||
</p>
|
||||
</div>
|
||||
<PaymentButton
|
||||
productId={id}
|
||||
className="bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-5 py-2.5 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
결제 테스트
|
||||
</PaymentButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
const faqItems = [
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
|
||||
interface BirthKey {
|
||||
birth_year: number;
|
||||
@@ -313,13 +312,10 @@ export default function SajuAISection({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PaymentButton
|
||||
productId="saju_detail"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all"
|
||||
>
|
||||
AI 상세 해석 받기 — 1,000원
|
||||
</PaymentButton>
|
||||
<p className="text-blue-200/40 text-xs mt-3">결제 후 즉시 AI 분석 시작 · 로그인 필요</p>
|
||||
<p className="inline-flex items-center gap-2 bg-white/10 text-blue-100/80 font-semibold px-7 py-3 rounded-xl">
|
||||
AI 상세 해석은 서비스 개편 준비 중입니다
|
||||
</p>
|
||||
<p className="text-blue-200/40 text-xs mt-3">사주 서비스 개편(Phase 2)에서 무료 제공 예정</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 포트원 V2 결제 채널 설정
|
||||
* 포트원 admin에서 채널 추가 후 각 Channel Key를 .env.local에 설정
|
||||
*/
|
||||
|
||||
export type PayMethod = 'CARD' | 'EASY_PAY';
|
||||
|
||||
export interface PaymentChannel {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // SVG or emoji placeholder
|
||||
channelKey: string;
|
||||
payMethod: PayMethod;
|
||||
}
|
||||
|
||||
export const PAYMENT_CHANNELS: PaymentChannel[] = [
|
||||
{
|
||||
id: 'card',
|
||||
label: '신용/체크카드',
|
||||
icon: '💳',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KPN ?? '',
|
||||
payMethod: 'CARD',
|
||||
},
|
||||
{
|
||||
id: 'kakaopay',
|
||||
label: '카카오페이',
|
||||
icon: '🟡',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KAKAOPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
{
|
||||
id: 'naverpay',
|
||||
label: '네이버페이',
|
||||
icon: '🟢',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_NAVERPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
{
|
||||
id: 'tosspay',
|
||||
label: '토스페이',
|
||||
icon: '🔵',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_TOSSPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
];
|
||||
|
||||
/** channelKey가 설정된 채널만 반환 */
|
||||
export function getActiveChannels(): PaymentChannel[] {
|
||||
return PAYMENT_CHANNELS.filter((ch) => ch.channelKey.length > 0);
|
||||
}
|
||||
143
lib/products.ts
143
lib/products.ts
@@ -1,143 +0,0 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
type: 'one_time' | 'monthly' | 'annual';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PRODUCTS: Record<string, Product> = {
|
||||
stock_starter_install: {
|
||||
id: 'stock_starter_install',
|
||||
name: '주식 스타터 설치',
|
||||
price: 99000,
|
||||
type: 'one_time',
|
||||
description: '1개 종목 자동 매매 설치',
|
||||
},
|
||||
stock_pro_install: {
|
||||
id: 'stock_pro_install',
|
||||
name: '주식 프로 설치',
|
||||
price: 199000,
|
||||
type: 'one_time',
|
||||
description: '5개 종목 + 전략 커스터마이징 설치',
|
||||
},
|
||||
stock_starter_monthly: {
|
||||
id: 'stock_starter_monthly',
|
||||
name: '주식 스타터 월 유지비',
|
||||
price: 29000,
|
||||
type: 'monthly',
|
||||
description: '스타터 월 유지보수 비용',
|
||||
},
|
||||
stock_pro_monthly: {
|
||||
id: 'stock_pro_monthly',
|
||||
name: '주식 프로 월 유지비',
|
||||
price: 49000,
|
||||
type: 'monthly',
|
||||
description: '프로 월 유지보수 비용',
|
||||
},
|
||||
saju_detail: {
|
||||
id: 'saju_detail',
|
||||
name: 'AI 사주 상세 리포트',
|
||||
price: 1000,
|
||||
type: 'one_time',
|
||||
description: 'AI 12가지 항목 상세 해석',
|
||||
},
|
||||
prompt_single: {
|
||||
id: 'prompt_single',
|
||||
name: '프롬프트 단건 설계',
|
||||
price: 30000,
|
||||
type: 'one_time',
|
||||
description: '업무 특화 프롬프트 1개 맞춤 설계 · 수정 1회 포함',
|
||||
},
|
||||
prompt_business: {
|
||||
id: 'prompt_business',
|
||||
name: '프롬프트 비즈니스 패키지',
|
||||
price: 99000,
|
||||
type: 'one_time',
|
||||
description: '업무 유형별 프롬프트 5개 설계 · 수정 3회 · 1:1 교육 포함',
|
||||
},
|
||||
prompt_team: {
|
||||
id: 'prompt_team',
|
||||
name: '프롬프트 팀/기업 패키지',
|
||||
price: 249000,
|
||||
type: 'one_time',
|
||||
description: '팀 전체 프롬프트 시스템 구축 · 10개 이상 설계 · 교육 자료 포함',
|
||||
},
|
||||
automation_basic: {
|
||||
id: 'automation_basic',
|
||||
name: '단순 업무 자동화',
|
||||
price: 50000,
|
||||
type: 'one_time',
|
||||
description: '단일 반복 업무 자동화 1건 개발 · 1~3일 납품',
|
||||
},
|
||||
automation_advanced: {
|
||||
id: 'automation_advanced',
|
||||
name: '업무 자동화 심화',
|
||||
price: 150000,
|
||||
type: 'one_time',
|
||||
description: '복합 업무 자동화 개발 · RPA·API 연동 · 1~2주 납품',
|
||||
},
|
||||
website_starter: {
|
||||
id: 'website_starter',
|
||||
name: '홈페이지 스타터 패키지',
|
||||
price: 200000,
|
||||
type: 'one_time',
|
||||
description: '5페이지 이내 반응형 홈페이지 · 기본 SEO · 3~5영업일 납품',
|
||||
},
|
||||
website_business: {
|
||||
id: 'website_business',
|
||||
name: '홈페이지 비즈니스 패키지',
|
||||
price: 1000000,
|
||||
type: 'one_time',
|
||||
description: '10페이지 이내 · 관리자 페이지 · SEO 최적화 · 1~2주 납품',
|
||||
},
|
||||
website_premium: {
|
||||
id: 'website_premium',
|
||||
name: '홈페이지 프리미엄 패키지',
|
||||
price: 2000000,
|
||||
type: 'one_time',
|
||||
description: '페이지 수 무제한 · 결제/회원 시스템 · DB 연동 · 일정 협의',
|
||||
},
|
||||
prompt_image_gen: {
|
||||
id: 'prompt_image_gen',
|
||||
name: 'AI 이미지 생성 마스터 프롬프트 패키지',
|
||||
price: 12900,
|
||||
type: 'one_time',
|
||||
description: '50종 이미지 생성 프롬프트 · 구도/조명/후처리 공식 포함 · 즉시 다운로드',
|
||||
},
|
||||
prompt_resume: {
|
||||
id: 'prompt_resume',
|
||||
name: 'AI 자소서·이력서 첨삭 마스터 프롬프트',
|
||||
price: 9900,
|
||||
type: 'one_time',
|
||||
description: '7가지 유형별 자소서 프롬프트 · STAR 기법 · ATS 최적화 · 즉시 다운로드',
|
||||
},
|
||||
prompt_email: {
|
||||
id: 'prompt_email',
|
||||
name: '비즈니스 이메일 마스터 프롬프트 패키지',
|
||||
price: 10900,
|
||||
type: 'one_time',
|
||||
description: '20종 비즈니스 이메일 프롬프트 · 상황별 템플릿 · 즉시 다운로드',
|
||||
},
|
||||
prompt_marketing: {
|
||||
id: 'prompt_marketing',
|
||||
name: '마케팅 카피·SNS 콘텐츠 프롬프트 패키지',
|
||||
price: 12900,
|
||||
type: 'one_time',
|
||||
description: '플랫폼별 카피 프롬프트 30종 · 광고 문구 · SNS 캡션 · 즉시 다운로드',
|
||||
},
|
||||
prompt_report: {
|
||||
id: 'prompt_report',
|
||||
name: '업무 보고서·기획서 작성 프롬프트 패키지',
|
||||
price: 10900,
|
||||
type: 'one_time',
|
||||
description: '보고서/기획서/회의록 유형별 프롬프트 25종 · 즉시 다운로드',
|
||||
},
|
||||
ai_kit_monthly: {
|
||||
id: 'ai_kit_monthly',
|
||||
name: 'AI 자동화 월 구독 키트',
|
||||
price: 19900,
|
||||
type: 'monthly',
|
||||
description: '소상공인·직장인을 위한 AI 자동화 도구 월 구독 · 매월 업데이트',
|
||||
},
|
||||
};
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -11,7 +11,6 @@
|
||||
"@anthropic-ai/sdk": "^0.79.0",
|
||||
"@google-analytics/data": "^5.2.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@portone/browser-sdk": "^0.1.3",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.99.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -1381,11 +1380,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@portone/browser-sdk": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@portone/browser-sdk/-/browser-sdk-0.1.3.tgz",
|
||||
"integrity": "sha512-GRmXb8gBLs/23CES1YRvzMggbL8+tFeH5WS9YSA6WUPu5/9kgRKKZLeC25N2raq3zVJd9OfDTeSf0tUr27vF1Q=="
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"@anthropic-ai/sdk": "^0.79.0",
|
||||
"@google-analytics/data": "^5.2.1",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@portone/browser-sdk": "^0.1.3",
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.99.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Reference in New Issue
Block a user