chore(phase0): PortOne 잔재 제거 — 계좌이체 단일 소스 확정, saju 결제 CTA 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:12:38 +09:00
parent 8e1cf9b4e1
commit 1e926fcb19
11 changed files with 4 additions and 729 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [

View File

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

View File

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

View File

@@ -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
View File

@@ -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",

View File

@@ -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",