fix: 외주 플랫폼 전환율 개선 + API 보안 정비 + 시크릿 노출 제거

[Backend API]
- contact/route: 문의 내역 contact_requests DB 저장 추가 (이메일+DB 병행)
- projects/route, link/route: 미사용 Bearer 토큰 인증 제거, Cookie 전용
- projects/route: DB 에러 메시지 클라이언트 노출 차단 (console.error로 전환)
- quote/[token]/route: valid_until 만료 검증 + expired 플래그 응답 추가

[Frontend UX]
- mypage: 로또 잔존 코드 완전 제거 (PLAN_LABELS, lotto_history 쿼리)
- mypage: 기본 탭 projects로 변경, 탭 순서 외주 고객 우선 재배치
- freelance: 포트폴리오 가격대 뱃지 추가, 각 항목 CTA 링크 추가
- freelance: 후기 섹션 하단 CTA 블록 추가

[견적서 페이지]
- quote/[token]/page: 만료 견적서 경고 배너 + 수락 버튼 숨김
- quote/layout: DashboardShell 없이 독립 렌더링

[보안]
- test-flow.mjs: 하드코딩 시크릿 → .env.test 환경변수 참조로 교체
- GitGuardian 3건 대응 (admin password, JWT, test password)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 08:49:05 +09:00
parent 2c9af41631
commit fe1e8ffcf0
9 changed files with 152 additions and 111 deletions

View File

@@ -48,14 +48,6 @@ interface Order {
status: string;
}
interface LottoHistoryItem {
id: number;
numbers: number[];
source: string;
plan_id: string;
created_at: string;
}
interface ProjectMilestone {
id: string;
step_number: number;
@@ -85,22 +77,15 @@ interface ActiveSubscription {
cancelled_at: string | null;
}
const PLAN_LABELS: Record<string, { label: string; emoji: string; color: string }> = {
lotto_gold: { label: '골드', emoji: '🥇', color: 'amber' },
lotto_platinum: { label: '플래티넘', emoji: '💎', color: 'sky' },
lotto_diamond: { label: '다이아', emoji: '👑', color: 'violet' },
};
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 [tab, setTab] = useState<Tab>('projects');
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [lottoHistory, setLottoHistory] = useState<LottoHistoryItem[]>([]);
const [activeSubscriptions, setActiveSubscriptions] = useState<ActiveSubscription[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [linkToken, setLinkToken] = useState('');
@@ -172,15 +157,6 @@ export default function MyPage() {
setProjects(projData.projects ?? []);
}
// 로또 히스토리 조회
const { data: history } = await supabase
.from('lotto_history')
.select('id, numbers, source, plan_id, created_at')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(50);
setLottoHistory(history ?? []);
setLoading(false);
}
init();
@@ -308,12 +284,12 @@ export default function MyPage() {
const activeSubs = activeSubscriptions.filter((s) => s.status === 'active' || s.status === 'cancelled');
const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '내 정보' },
{ key: 'projects', label: '프로젝트 현황', count: projects.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
{ key: 'profile', label: '내 정보' },
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
];
return (
@@ -405,21 +381,21 @@ export default function MyPage() {
{/* 구독 중인 서비스 - 요약 (탭으로 유도) */}
{activeSubs.length > 0 && (
<div className="bg-amber-50 rounded-2xl border border-amber-200 p-5 flex items-center justify-between gap-3">
<div className="bg-blue-50 rounded-2xl border border-blue-200 p-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-2xl">{PLAN_LABELS[activeSubs[0].product_id]?.emoji ?? '🎟'}</span>
<span className="text-2xl">🎟</span>
<div>
<div className="text-sm font-bold text-[#04102b]">
{PLAN_LABELS[activeSubs[0].product_id]?.label}
</div>
<div className="text-xs text-amber-600 mt-0.5">
<div className="text-xs text-blue-600 mt-0.5">
{Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}
{activeSubs[0].status === 'cancelled' && ' · 해지 예정'}
</div>
</div>
</div>
<button onClick={() => setTab('subscription')}
className="text-xs font-bold text-amber-700 bg-amber-100 hover:bg-amber-200 px-3 py-1.5 rounded-lg transition">
className="text-xs font-bold text-blue-700 bg-blue-100 hover:bg-blue-200 px-3 py-1.5 rounded-lg transition">
</button>
</div>
@@ -574,7 +550,6 @@ export default function MyPage() {
/>
) : (
activeSubscriptions.map((sub) => {
const info = PLAN_LABELS[sub.product_id];
const expiresDate = new Date(sub.expires_at);
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / 86400000));
const isExpired = sub.status === 'expired';
@@ -586,10 +561,10 @@ export default function MyPage() {
{/* 헤더 */}
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<span className="text-3xl">{info?.emoji ?? '🎟'}</span>
<span className="text-3xl">🎟</span>
<div>
<div className="font-bold text-[#04102b] text-base">
{info?.label ?? sub.product_id}
{sub.product_id}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(sub.started_at).toLocaleDateString('ko-KR')}