diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 52e8b97..70a9fd6 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -8,6 +8,8 @@ import { getClientIp, INPUT_LIMITS, } from '@/lib/security'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { createClient } from '@/lib/supabase/server'; const resend = new Resend(process.env.RESEND_API_KEY); @@ -58,27 +60,67 @@ export async function POST(request: Request) { // message는 pre-wrap으로 렌더링되므로 반드시 이스케이프 const safeMessage = escapeHtml(message); - await resend.emails.send({ - from: 'onboarding@resend.dev', - to: ['bgg8988@gmail.com'], - replyTo: email, - subject: `[쟁승메이드] 새로운 문의: ${safeSubject}`, - html: ` -

새로운 프로젝트 문의가 도착했습니다

-
-

이름: ${safeName}

-

연락처: ${safePhone}

-

이메일: ${safeEmail}

-

서비스: ${safeService}

-
-

문의 내용:

-

${safeMessage}

-
-

- 이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다. -

- `, - }); + // ── 로그인 사용자 확인 (optional) ───────────────────────── + let userId: string | null = null; + try { + const supabase = await createClient(); + const { data } = await supabase.auth.getUser(); + userId = data?.user?.id ?? null; + } catch { + // 비로그인 상태 — 무시 + } + + // ── 이메일 전송 ────────────────────────────────────────── + let emailSent = true; + try { + await resend.emails.send({ + from: 'onboarding@resend.dev', + to: ['bgg8988@gmail.com'], + replyTo: email, + subject: `[쟁승메이드] 새로운 문의: ${safeSubject}`, + html: ` +

새로운 프로젝트 문의가 도착했습니다

+
+

이름: ${safeName}

+

연락처: ${safePhone}

+

이메일: ${safeEmail}

+

서비스: ${safeService}

+
+

문의 내용:

+

${safeMessage}

+
+

+ 이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다. +

+ `, + }); + } catch (emailError) { + console.error('[Contact] Email send error:', emailError); + emailSent = false; + } + + // ── DB 저장 (이메일 성공/실패 무관) ────────────────────── + try { + const admin = createAdminClient(); + await admin.from('contact_requests').insert({ + name, + email, + phone: phone || null, + service: service || null, + message, + user_id: userId, + created_at: new Date().toISOString(), + }); + } catch (dbError) { + console.error('[Contact] DB insert error:', dbError); + } + + if (!emailSent) { + return NextResponse.json( + { error: '메일 전송에 실패했습니다. 다시 시도해주세요.' }, + { status: 500 } + ); + } return NextResponse.json( { success: true, message: '문의가 성공적으로 전송되었습니다!' }, @@ -86,9 +128,9 @@ export async function POST(request: Request) { ); } catch (error) { // 클라이언트에 내부 오류 상세 노출 금지 - console.error('[Contact] Email send error:', error); + console.error('[Contact] Unexpected error:', error); return NextResponse.json( - { error: '메일 전송에 실패했습니다. 다시 시도해주세요.' }, + { error: '문의 처리 중 오류가 발생했습니다. 다시 시도해주세요.' }, { status: 500 } ); } diff --git a/app/api/projects/link/route.ts b/app/api/projects/link/route.ts index 71c7cb0..81e0151 100644 --- a/app/api/projects/link/route.ts +++ b/app/api/projects/link/route.ts @@ -1,29 +1,12 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { createClient } from '@/lib/supabase/server'; -import { createClient as createSupabaseClient } from '@supabase/supabase-js'; export const runtime = 'nodejs'; export async function POST(request: Request) { - // Cookie 기반 또는 Bearer 토큰 기반 인증 모두 지원 - const authHeader = request.headers.get('authorization'); - let user = null; - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - const client = createSupabaseClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - ); - const { data } = await client.auth.getUser(token); - user = data?.user ?? null; - } else { - const supabase = await createClient(); - const { data } = await supabase.auth.getUser(); - user = data?.user ?? null; - } - + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); @@ -56,7 +39,10 @@ export async function POST(request: Request) { .update({ user_id: user.id, updated_at: new Date().toISOString() }) .eq('id', quote.id); - if (updateErr) return NextResponse.json({ error: updateErr.message }, { status: 500 }); + if (updateErr) { + console.error('[Projects/Link] DB update error:', updateErr.message); + return NextResponse.json({ error: '견적서 연결에 실패했습니다. 다시 시도해주세요.' }, { status: 500 }); + } return NextResponse.json({ success: true, quoteId: quote.id }); } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 7e4900f..bcb83a3 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,28 +1,12 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { createClient } from '@/lib/supabase/server'; -import { createClient as createSupabaseClient } from '@supabase/supabase-js'; export const runtime = 'nodejs'; -export async function GET(request: Request) { - // Cookie 기반 또는 Bearer 토큰 기반 인증 모두 지원 - const authHeader = request.headers.get('authorization'); - let user = null; - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - const client = createSupabaseClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - ); - const { data } = await client.auth.getUser(token); - user = data?.user ?? null; - } else { - const supabase = await createClient(); - const { data } = await supabase.auth.getUser(); - user = data?.user ?? null; - } +export async function GET() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const admin = createAdminClient(); @@ -34,7 +18,10 @@ export async function GET(request: Request) { .in('status', ['sent', 'accepted', 'in_progress', 'completed', 'delivered']) .order('created_at', { ascending: false }); - if (qErr) return NextResponse.json({ error: qErr.message }, { status: 500 }); + if (qErr) { + console.error('[Projects] DB query error:', qErr.message); + return NextResponse.json({ error: '프로젝트 정보를 불러올 수 없습니다.' }, { status: 500 }); + } if (!quotes?.length) return NextResponse.json({ projects: [] }); const quoteIds = quotes.map((q) => q.id); diff --git a/app/api/quote/[token]/route.ts b/app/api/quote/[token]/route.ts index 10b919a..4987d69 100644 --- a/app/api/quote/[token]/route.ts +++ b/app/api/quote/[token]/route.ts @@ -15,7 +15,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token: .single(); if (error || !data) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - return NextResponse.json({ quote: data }); + + // 만료 검증: valid_until이 현재 시간보다 과거이면 expired 플래그 추가 + const expired = data.valid_until + ? new Date(data.valid_until).getTime() < Date.now() + : false; + + return NextResponse.json({ quote: data, expired }); } // 고객이 견적 수락 diff --git a/app/freelance/page.tsx b/app/freelance/page.tsx index a43c915..f1cb61e 100644 --- a/app/freelance/page.tsx +++ b/app/freelance/page.tsx @@ -13,6 +13,7 @@ const portfolio = [ tags: ['Next.js', 'Tailwind CSS', 'Vercel', 'SEO'], status: '납품 완료', statusType: 'done', + priceRange: '50~200만원', accentColor: 'text-indigo-400', accentBg: 'bg-[#0d0a2e]', borderAccent: 'border-indigo-400/20', @@ -25,6 +26,7 @@ const portfolio = [ tags: ['Python', 'Gmail API', 'Google Apps Script'], status: '납품 완료', statusType: 'done', + priceRange: '30~150만원', accentColor: 'text-red-400', accentBg: 'bg-[#200a0a]', borderAccent: 'border-red-400/20', @@ -37,6 +39,7 @@ const portfolio = [ tags: ['Python', 'Selenium', 'Telegram Bot'], status: '납품 완료', statusType: 'done', + priceRange: '30~150만원', accentColor: 'text-violet-400', accentBg: 'bg-[#0d0a2e]', borderAccent: 'border-violet-400/20', @@ -49,6 +52,7 @@ const portfolio = [ tags: ['Python', 'OpenPyXL', 'ReportLab'], status: '납품 완료', statusType: 'done', + priceRange: '30~150만원', accentColor: 'text-cyan-400', accentBg: 'bg-[#012030]', borderAccent: 'border-cyan-400/20', @@ -61,6 +65,7 @@ const portfolio = [ tags: ['Python', '공공데이터 API', 'PostgreSQL', 'Telegram'], status: '납품 완료', statusType: 'done', + priceRange: '30~150만원', accentColor: 'text-blue-400', accentBg: 'bg-[#04102b]', borderAccent: 'border-blue-400/20', @@ -341,6 +346,12 @@ export default function FreelancePage() { ))} +
+ {item.priceRange} + + 비슷한 서비스 의뢰하기 → + +
))} @@ -408,6 +419,13 @@ export default function FreelancePage() {

* 의뢰인 동의 하에 게시된 후기입니다. 전체 대화 내역 공개 요청 시 제공 가능합니다.

+ +
+ + 무료 상담 시작하기 + +

24시간 내 답변 · 상담은 무료입니다

+
@@ -581,7 +599,7 @@ export default function FreelancePage() { {/* ─── 문의 폼 ─── */} -
+

CONTACT

diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index afc856e..1afc990 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -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 = { - 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(null); const [loading, setLoading] = useState(true); - const [tab, setTab] = useState('profile'); + const [tab, setTab] = useState('projects'); const [sajuRecords, setSajuRecords] = useState([]); const [payments, setPayments] = useState([]); const [orders, setOrders] = useState([]); - const [lottoHistory, setLottoHistory] = useState([]); const [activeSubscriptions, setActiveSubscriptions] = useState([]); const [projects, setProjects] = useState([]); 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 && ( -
+
- {PLAN_LABELS[activeSubs[0].product_id]?.emoji ?? '🎟'} + 🎟
- 로또 {PLAN_LABELS[activeSubs[0].product_id]?.label} 구독 중 + 서비스 구독 중
-
+
{Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}일 후 만료 {activeSubs[0].status === 'cancelled' && ' · 해지 예정'}
@@ -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() { {/* 헤더 */}
- {info?.emoji ?? '🎟'} + 🎟
- 로또 번호 추천 {info?.label ?? sub.product_id} + {sub.product_id}
{new Date(sub.started_at).toLocaleDateString('ko-KR')} 시작 diff --git a/app/quote/[token]/page.tsx b/app/quote/[token]/page.tsx index 2b2eab5..af43971 100644 --- a/app/quote/[token]/page.tsx +++ b/app/quote/[token]/page.tsx @@ -60,6 +60,8 @@ export default function QuotePage() { .finally(() => setLoading(false)); }, [token]); + const isExpired = quote?.valid_until ? new Date(quote.valid_until) < new Date() : false; + const requiredItems = quote?.items.filter((i) => !i.optional) ?? []; const optionalItems = quote?.items.filter((i) => i.optional) ?? []; @@ -227,6 +229,19 @@ export default function QuotePage() {
+ {/* 만료 배너 */} + {isExpired && ( +
+
+ +
+
이 견적서는 만료되었습니다
+
유효기간({quote.valid_until?.slice(0, 10)})이 지났습니다. 새 견적이 필요하시면 문의해 주세요.
+
+
+
+ )} + {/* 본문 */}
@@ -445,7 +460,7 @@ export default function QuotePage() {
{/* 하단 고정 바 — 견적 수락 */} - {quote.status !== 'accepted' && quote.status !== 'rejected' && ( + {quote.status !== 'accepted' && quote.status !== 'rejected' && !isExpired && (
diff --git a/app/quote/layout.tsx b/app/quote/layout.tsx new file mode 100644 index 0000000..3dca99c --- /dev/null +++ b/app/quote/layout.tsx @@ -0,0 +1,3 @@ +export default function QuoteLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/scripts/test-flow.mjs b/scripts/test-flow.mjs index c29f34c..24de8f7 100644 --- a/scripts/test-flow.mjs +++ b/scripts/test-flow.mjs @@ -13,17 +13,26 @@ */ import { createClient } from '@supabase/supabase-js'; +import { config } from 'dotenv'; -const BASE_URL = 'http://localhost:3000'; -const SUPABASE_URL = 'https://avickbbhyhlnqbbqfzws.supabase.co'; -const SUPABASE_ANON_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImF2aWNrYmJoeWhsbnFiYnFmendzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MjY0OTQsImV4cCI6MjA4NjQwMjQ5NH0.CFJCWlZpVzv3FQohhVuQLS8GbkvJrc7T7zDwltWuVZw'; -const ADMIN_ID = 'jaengseung_admin'; -const ADMIN_PASSWORD = 'JaengSeung@Admin2026!'; +// .env.test 파일에서 환경변수 로드 +config({ path: '.env.test' }); + +const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; +const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const ADMIN_ID = process.env.TEST_ADMIN_ID; +const ADMIN_PASSWORD = process.env.TEST_ADMIN_PASSWORD; + +if (!SUPABASE_URL || !SUPABASE_ANON_KEY || !ADMIN_ID || !ADMIN_PASSWORD) { + console.error('❌ .env.test 파일에 필수 환경변수가 없습니다.'); + console.error(' NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, TEST_ADMIN_ID, TEST_ADMIN_PASSWORD'); + process.exit(1); +} // 테스트 계정 정보 const TEST_EMAIL = `testuser_${Date.now()}@test-jaengseung.com`; -const TEST_PASSWORD = 'Test@2026!'; +const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test@2026!'; const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); @@ -312,8 +321,8 @@ function printBrowserGuide(quote, email, password, linked) { console.log(` [관리자 화면] URL: http://localhost:3000/admin/login - ID : jaengseung_admin - PW : JaengSeung@Admin2026! + ID : ${ADMIN_ID} + PW : (환경변수 TEST_ADMIN_PASSWORD 참조) → 로그인 후 견적서 목록에서 아래 견적서 클릭: "${quote.title}" (${quote.id.slice(0, 8)}...)