feat: 포트원 V2 결제 마이그레이션 + 법적 페이지 추가 (PG 심사용)

- 토스페이먼츠 SDK → 포트원 V2 (@portone/browser-sdk) 전환
- 4채널 결제수단 선택 UI: 카드(KPN)/카카오페이/네이버페이/토스페이
- 서버 결제 검증 API를 포트원 V2 조회 방식으로 변경
- 이용약관(/legal/terms), 개인정보처리방침(/legal/privacy), 환불정책(/legal/refund) 페이지 생성
- 푸터에 법적 페이지 링크 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:52:22 +09:00
parent 5d2fd4be1f
commit 769544b453
11 changed files with 728 additions and 182 deletions

View File

@@ -15,20 +15,12 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { paymentKey, orderId, amount } = body;
const { paymentId } = body;
// ── 기본 파라미터 검증 ────────────────────────────────────
if (!paymentKey || !orderId || amount === undefined) {
if (!paymentId || typeof paymentId !== 'string' || paymentId.length > 200) {
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
}
// 타입 강제 검증
if (
typeof paymentKey !== 'string' || paymentKey.length > 200 ||
typeof orderId !== 'string' || orderId.length > 200 ||
typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount)
) {
return NextResponse.json({ error: '잘못된 파라미터 형식' }, { status: 400 });
}
// ── 로그인 사용자 확인 ────────────────────────────────────
const supabase = await createClient();
@@ -37,66 +29,73 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
// ── DB에서 주문 확인 (금액 서버사이드 검증) ───────────────
// ── DB에서 주문 확인 ──────────────────────────────────────
const { data: order, error: orderFetchError } = await supabase
.from('orders')
.select('*')
.eq('id', orderId)
.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 });
}
// 서버 DB 금액과 비교 (클라이언트 금액 위조 방어)
if (order.amount !== amount) {
console.warn(`[Payment] 금액 불일치 orderId=${orderId} db=${order.amount} req=${amount} user=${user.id}`);
return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 });
}
if (order.status === 'paid') {
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
}
// ── 토스페이먼츠 서버 승인 ────────────────────────────────
const secretKey = process.env.TOSS_SECRET_KEY;
if (!secretKey) {
console.error('[Payment] TOSS_SECRET_KEY 미설정');
// ── 포트원 V2 결제 조회 API ───────────────────────────────
const apiSecret = process.env.PORTONE_API_SECRET;
if (!apiSecret) {
console.error('[Payment] PORTONE_API_SECRET 미설정');
return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
}
if (!secretKey.startsWith('test_') && process.env.NODE_ENV === 'development') {
console.warn('[Payment] WARNING: live Toss key detected in development!');
}
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 }),
});
const portoneRes = await fetch(
`https://api.portone.io/payments/${encodeURIComponent(paymentId)}`,
{
method: 'GET',
headers: {
Authorization: `PortOne ${apiSecret}`,
'Content-Type': 'application/json',
},
}
);
if (!tossRes.ok) {
const err = await tossRes.json().catch(() => ({}));
// 내부 에러 코드는 서버 로그에만 기록
console.error(`[Payment] Toss 승인 실패 orderId=${orderId} code=${err.code} msg=${err.message}`);
if (!portoneRes.ok) {
const err = await portoneRes.json().catch(() => ({}));
console.error(`[Payment] 포트원 조회 실패 paymentId=${paymentId} status=${portoneRes.status}`, err);
return NextResponse.json(
{ error: '결제 인에 실패했습니다. 카드사 또는 고객센터에 문의해주세요.' },
{ error: '결제 인에 실패했습니다. 고객센터에 문의해주세요.' },
{ status: 400 }
);
}
const tossData = await tossRes.json();
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', orderId);
.eq('id', paymentId);
if (updateError) {
console.error('[Payment] Order update error:', updateError.message);
@@ -104,14 +103,15 @@ export async function POST(request: NextRequest) {
}
// ── payments 레코드 생성 ──────────────────────────────────
const pgPaymentId = paymentData.pgResponse?.pgTxId ?? paymentData.paymentId ?? paymentId;
const { error: paymentError } = await supabase.from('payments').insert({
user_id: order.user_id,
order_id: orderId,
order_id: paymentId,
product_name: order.metadata?.product_name ?? order.product_id,
amount: order.amount,
status: 'paid',
pg_provider: 'toss',
pg_payment_key: paymentKey,
pg_provider: 'portone_kcp',
pg_payment_key: pgPaymentId,
});
if (paymentError) {
@@ -119,7 +119,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
}
return NextResponse.json({ success: true, data: tossData });
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 });

View File

@@ -55,6 +55,11 @@ export default function DashboardShell({ children }: { children: React.ReactNode
<span>전화: 010-3907-1392</span>
<span>이메일: bgg8988@gmail.com</span>
</div>
<div className="flex flex-wrap gap-x-3 mt-3">
<a href="/legal/terms" className="text-slate-400 hover:text-[#1a56db] transition underline underline-offset-2"></a>
<a href="/legal/privacy" className="text-slate-400 hover:text-[#1a56db] transition underline underline-offset-2"></a>
<a href="/legal/refund" className="text-slate-400 hover:text-[#1a56db] transition underline underline-offset-2"> </a>
</div>
<p className="mt-2 text-slate-400">© 2025 . All rights reserved.</p>
</div>
</footer>

View File

@@ -4,6 +4,8 @@ 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;
@@ -15,11 +17,14 @@ interface PaymentButtonProps {
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 handlePayment = async () => {
const processPayment = async (channel: PaymentChannel) => {
setShowMethodPicker(false);
setLoading(true);
try {
// 1. 로그인 확인
@@ -29,85 +34,171 @@ export default function PaymentButton({ productId, className, style, children, r
return;
}
// 2. 프로필 없으면 생성 (Google OAuth 등으로 트리거 미실행된 경우 대비)
// 2. 프로필 없으면 생성
await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
// 3. Supabase에 order 생성
const orderId = crypto.randomUUID();
const paymentId = `order_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
const { error: orderError } = await supabase
.from('orders')
.insert({
id: orderId,
id: paymentId,
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: { product_name: product.name },
metadata: { product_name: product.name, pay_channel: channel.id },
});
if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
// 4. 토스페이먼츠 결제창 호출
// dev: NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_* → 테스트 결제 (실제 청구 없음)
// prod: NEXT_PUBLIC_TOSS_CLIENT_KEY=live_ck_* → 실결제 (Vercel 환경변수에 설정)
const { loadTossPayments } = await import('@tosspayments/tosspayments-sdk');
const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!;
const tossPayments = await loadTossPayments(clientKey);
// 4. 포트원 V2 결제 요청
const storeId = process.env.NEXT_PUBLIC_PORTONE_STORE_ID!;
const payment = tossPayments.payment({
customerKey: user.id,
});
await payment.requestPayment({
method: 'CARD',
amount: {
currency: 'KRW',
value: product.price,
},
orderId,
const response = await PortOne.requestPayment({
storeId,
channelKey: channel.channelKey,
paymentId,
orderName: product.name,
successUrl: `${window.location.origin}/payment/success${returnUrl ? '?returnUrl=' + encodeURIComponent(returnUrl) : ''}`,
failUrl: `${window.location.origin}/payment/fail`,
customerEmail: user.email,
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') {
alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
console.error(err);
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_TOSS_CLIENT_KEY?.startsWith('test_');
const isTestMode = process.env.NEXT_PUBLIC_PORTONE_STORE_ID?.includes('test')
|| process.env.NODE_ENV === 'development';
return (
<div style={{ display: style ? 'block' : 'inline-block', position: 'relative' }}>
<button
onClick={handlePayment}
disabled={loading}
className={className}
style={style}
>
{loading ? '결제 처리 중...' : children}
</button>
{/* dev/test 환경에서만 표시되는 배지 — 실수로 실결제 누르는 것 방지 */}
{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 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>
)}
</div>
</>
);
}

177
app/legal/privacy/page.tsx Normal file
View File

@@ -0,0 +1,177 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '개인정보처리방침 | 쟁승메이드',
description: '쟁승메이드 개인정보처리방침',
};
export default function PrivacyPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>
( &quot;&quot;) ,
·.
</p>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">1. </h2>
<p> .</p>
<table className="w-full text-sm border border-slate-200 mt-3">
<thead>
<tr className="bg-slate-50">
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-slate-200 px-3 py-2"></td>
<td className="border border-slate-200 px-3 py-2">, ()</td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2">, , , </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2"> (PG사를 , )</td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2">, , </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2"> IP, , , </td>
</tr>
</tbody>
</table>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">2. · </h2>
<ul className="list-disc pl-5 space-y-1">
<li> 이행: 문의 , , </li>
<li> 관리: 회원 , , </li>
<li> 개선: 접속 , </li>
<li>·홍보: 신규 , ( )</li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">3. </h2>
<ul className="list-disc pl-5 space-y-1">
<li> 정보: 회원 ( )</li>
<li> 내역: 문의 3 ()</li>
<li> 기록: 5년 ()</li>
<li> 로그: 3개월 ()</li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">4. 3 </h2>
<p>
3 .
, .
</p>
<ul className="list-disc pl-5 space-y-1">
<li> </li>
<li> </li>
<li> PG사(, ) </li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">5. </h2>
<table className="w-full text-sm border border-slate-200 mt-3">
<thead>
<tr className="bg-slate-50">
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"></th>
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-slate-200 px-3 py-2">()</td>
<td className="border border-slate-200 px-3 py-2"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">Supabase Inc.</td>
<td className="border border-slate-200 px-3 py-2"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">Vercel Inc.</td>
<td className="border border-slate-200 px-3 py-2"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">Resend Inc.</td>
<td className="border border-slate-200 px-3 py-2"> </td>
</tr>
</tbody>
</table>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">6. </h2>
<ul className="list-disc pl-5 space-y-1">
<li> 절차: 보유 </li>
<li> 방법: 전자적 , </li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">7. </h2>
<p> .</p>
<ul className="list-disc pl-5 space-y-1">
<li> </li>
<li> · </li>
<li> </li>
<li> ( )</li>
</ul>
<p className="mt-2">
(bgg8988@gmail.com) 10 .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">8. (Cookie) </h2>
<p>
Google Analytics를 , .
, .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">9. </h2>
<ul className="list-none space-y-0.5">
<li>성명: 박재오</li>
<li>직위: 대표</li>
<li>이메일: bgg8988@gmail.com</li>
<li>전화: 010-3907-1392</li>
</ul>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">10. </h2>
<p> · .</p>
<ul className="list-disc pl-5 space-y-1">
<li>개인정보분쟁조정위원회: 1833-6972 (kopico.go.kr)</li>
<li>개인정보침해신고센터: 118 (privacy.kisa.or.kr)</li>
<li> 사이버수사과: 1301 (spo.go.kr)</li>
<li> 사이버수사국: 182 (ecrm.cyber.go.kr)</li>
</ul>
</section>
<section className="border-t border-slate-200 pt-6 mt-10">
<p className="text-slate-400 text-xs">
시행일자: 2025년 4 7<br />
상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822
</p>
</section>
</div>
</div>
);
}

148
app/legal/refund/page.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '환불 정책 | 쟁승메이드',
description: '쟁승메이드 환불 및 취소 정책',
};
export default function RefundPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"> </h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>
( &quot;&quot;) .
.
</p>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">1. ( )</h2>
<p className="font-medium text-slate-700">대상: 프롬프트 , AI , </p>
<table className="w-full text-sm border border-slate-200 mt-3">
<thead>
<tr className="bg-slate-50">
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"></th>
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-slate-200 px-3 py-2"> / </td>
<td className="border border-slate-200 px-3 py-2 text-emerald-600 font-medium"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">/ 7 </td>
<td className="border border-slate-200 px-3 py-2 text-amber-600 font-medium"> ( )</td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">7 </td>
<td className="border border-slate-200 px-3 py-2 text-slate-400"> </td>
</tr>
</tbody>
</table>
<p className="text-xs text-slate-400 mt-2">
* 17 , .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">2. </h2>
<p className="font-medium text-slate-700">대상: AI , , </p>
<table className="w-full text-sm border border-slate-200 mt-3">
<thead>
<tr className="bg-slate-50">
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"></th>
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2 text-emerald-600 font-medium"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> 7 </td>
<td className="border border-slate-200 px-3 py-2 text-amber-600 font-medium"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2">7 </td>
<td className="border border-slate-200 px-3 py-2 text-slate-400"> , </td>
</tr>
</tbody>
</table>
<p className="text-xs text-slate-400 mt-2">
* (bgg8988@gmail.com) .
, .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">3. </h2>
<p className="font-medium text-slate-700">대상: 홈페이지 , , </p>
<table className="w-full text-sm border border-slate-200 mt-3">
<thead>
<tr className="bg-slate-50">
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"></th>
<th className="border border-slate-200 px-3 py-2 text-left font-semibold"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-slate-200 px-3 py-2"> ( )</td>
<td className="border border-slate-200 px-3 py-2 text-emerald-600 font-medium"> </td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2 text-amber-600 font-medium"> ()</td>
</tr>
<tr>
<td className="border border-slate-200 px-3 py-2"> </td>
<td className="border border-slate-200 px-3 py-2 text-slate-400"> ( )</td>
</tr>
</tbody>
</table>
<p className="text-xs text-slate-400 mt-2">
* 50% / 50% , .
<br />
* 1 1% .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">4. </h2>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<ol className="list-decimal pl-5 space-y-2">
<li>(<strong>bgg8988@gmail.com</strong>)
<ul className="list-disc pl-5 mt-1 text-xs text-slate-500">
<li>: [ ] </li>
<li>본문: 성함, , </li>
</ul>
</li>
<li> <strong> 3 </strong> </li>
<li> ( 3~7 )</li>
</ol>
</div>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">5. </h2>
<ul className="list-disc pl-5 space-y-1">
<li> / </li>
<li> 7 </li>
<li> </li>
<li> </li>
</ul>
</section>
<section className="border-t border-slate-200 pt-6 mt-10">
<p className="text-slate-400 text-xs">
시행일자: 2025년 4 7<br />
상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822<br />
문의: bgg8988@gmail.com · 010-3907-1392
</p>
</section>
</div>
</div>
);
}

138
app/legal/terms/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '이용약관 | 쟁승메이드',
description: '쟁승메이드 서비스 이용약관',
};
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">1 ()</h2>
<p>
( &quot;&quot;) (jaengseung-made.com)
( &quot;&quot;) , ·
.
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">2 ()</h2>
<ol className="list-decimal pl-5 space-y-1">
<li>&quot;&quot; , AI , , , .</li>
<li>&quot;&quot; .</li>
<li>&quot;&quot; , .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">3 ( )</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .</li>
<li> , 7 .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">4 ( )</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .
<ul className="list-disc pl-5 mt-1 space-y-0.5">
<li> </li>
<li> </li>
<li> </li>
<li>AI </li>
<li>AI </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</li>
<li> .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">5 ( )</h2>
<p>
·, ,
. .
</p>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">6 ()</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .</li>
<li> . , .
<ul className="list-disc pl-5 mt-1 space-y-0.5">
<li> </li>
<li> </li>
<li> </li>
</ul>
</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">7 ( )</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .</li>
<li> , (, , ) .</li>
<li> .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">8 ( )</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> , , .</li>
<li> .
<ul className="list-disc pl-5 mt-1 space-y-0.5">
<li> </li>
<li> , </li>
<li> 3 </li>
<li> </li>
</ul>
</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">9 ( )</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .</li>
<li> .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">10 ()</h2>
<ol className="list-decimal pl-5 space-y-1">
<li> .</li>
<li>AI ( , ) , .</li>
<li> .</li>
</ol>
</section>
<section>
<h2 className="text-lg font-bold text-slate-800 mt-8 mb-3">11 ( )</h2>
<p>
, .
</p>
</section>
<section className="border-t border-slate-200 pt-6 mt-10">
<p className="text-slate-400 text-xs">
시행일자: 2025년 4 7<br />
상호: 쟁승메이드 · 대표: 박재오 · 사업자등록번호: 267-53-00822
</p>
</section>
</div>
</div>
);
}

View File

@@ -17,10 +17,10 @@ function FailContent() {
</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' ? '결제 취소' : '결제 실패'}
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2">
{code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
{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">

View File

@@ -1,82 +1,12 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Suspense } from 'react';
import { useSearchParams } 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-[#1a56db] hover:bg-[#1e4fc2] text-white px-5 py-2.5 rounded-xl font-semibold text-sm transition">
</Link>
</div>
</div>
);
}
const paymentId = params.get('paymentId');
return (
<div className="text-center py-20 px-6">
@@ -89,10 +19,10 @@ function SuccessContent() {
</div>
<h2 className="text-2xl font-extrabold text-[#04102b] mb-2"> !</h2>
{productName && (
<p className="text-slate-500 text-sm mb-1">{productName}</p>
{paymentId && (
<p className="text-slate-400 text-xs mb-1">: {paymentId}</p>
)}
<p className="text-slate-400 text-sm mb-8">
<p className="text-slate-500 text-sm mb-8">
.
</p>
<div className="flex justify-center gap-3 flex-wrap">

50
lib/payment-channels.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* 포트원 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);
}

13
package-lock.json generated
View File

@@ -11,9 +11,9 @@
"@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",
"@tosspayments/tosspayments-sdk": "^2.6.0",
"cheerio": "^1.2.0",
"dotenv": "^17.3.1",
"lunar-javascript": "^1.7.7",
@@ -1358,6 +1358,11 @@
"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",
@@ -1821,12 +1826,6 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tosspayments/tosspayments-sdk": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tosspayments/tosspayments-sdk/-/tosspayments-sdk-2.6.0.tgz",
"integrity": "sha512-d/9tCj5d+Jbj312bWYXo9dtGNzQaWRxTKWmw6rTwuDgw5g4mrJWV9dP9qiN6/x9PYaphbYDjxGenguHoVQXTGA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -12,9 +12,9 @@
"@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",
"@tosspayments/tosspayments-sdk": "^2.6.0",
"cheerio": "^1.2.0",
"dotenv": "^17.3.1",
"lunar-javascript": "^1.7.7",