feat: 프로젝트 API Bearer 토큰 인증 + E2E 테스트 스크립트 + 크몽 마케팅 이미지
- app/api/projects, link/route: Cookie + Bearer 토큰 이중 인증 지원 (E2E 테스트 대응) - app/mypage: 로또 기록 탭 제거, 구독 빈 상태 프롬프트 서비스로 변경 - scripts/test-flow.mjs: 견적서 발송→연결→마일스톤 진행 E2E 테스트 스크립트 - supabase/migrations/003: quotes RLS 비활성화 (관리자 서버 전용 접근) - marketing/kmong-images: 크몽 서비스 A 상세 이미지 5장 (HTML 스크린샷용) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,29 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createAdminClient } from '@/lib/supabase/admin';
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data } = await supabase.auth.getUser();
|
||||||
|
user = data?.user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createAdminClient } from '@/lib/supabase/admin';
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET() {
|
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 supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data } = await supabase.auth.getUser();
|
||||||
|
user = data?.user ?? null;
|
||||||
|
}
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const admin = createAdminClient();
|
const admin = createAdminClient();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'profile' | 'projects' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders';
|
type Tab = 'profile' | 'projects' | 'subscription' | 'saju' | 'payments' | 'orders';
|
||||||
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||||
|
|
||||||
interface SajuRecord {
|
interface SajuRecord {
|
||||||
@@ -311,7 +311,6 @@ export default function MyPage() {
|
|||||||
{ key: 'profile', label: '내 정보' },
|
{ key: 'profile', label: '내 정보' },
|
||||||
{ key: 'projects', label: '프로젝트 현황', count: projects.length || undefined },
|
{ key: 'projects', label: '프로젝트 현황', count: projects.length || undefined },
|
||||||
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
|
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
|
||||||
{ key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined },
|
|
||||||
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
|
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
|
||||||
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
|
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
|
||||||
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
|
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
|
||||||
@@ -546,17 +545,6 @@ export default function MyPage() {
|
|||||||
<div className="text-xs text-slate-500">새 사주 보기</div>
|
<div className="text-xs text-slate-500">새 사주 보기</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/services/lotto/recommend" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-amber-300 hover:bg-amber-50/50 transition group">
|
|
||||||
<div className="w-9 h-9 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-[#04102b]">로또 번호 추천</div>
|
|
||||||
<div className="text-xs text-slate-500">구독자 전용</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
|
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
|
||||||
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
||||||
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -580,9 +568,9 @@ export default function MyPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon="📦"
|
icon="📦"
|
||||||
title="활성 구독이 없습니다"
|
title="활성 구독이 없습니다"
|
||||||
desc="로또 번호 추천 서비스를 구독하면 여기서 관리할 수 있습니다"
|
desc="구독 중인 서비스가 없습니다"
|
||||||
linkHref="/services/lotto"
|
linkHref="/services/prompt"
|
||||||
linkLabel="구독 플랜 보기"
|
linkLabel="서비스 둘러보기"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
activeSubscriptions.map((sub) => {
|
activeSubscriptions.map((sub) => {
|
||||||
@@ -662,9 +650,9 @@ export default function MyPage() {
|
|||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<a href="/services/lotto/recommend"
|
<a href="/freelance"
|
||||||
className="flex-1 text-center py-2 text-sm font-bold text-white bg-amber-500 hover:bg-amber-400 rounded-xl transition shadow-sm">
|
className="flex-1 text-center py-2 text-sm font-bold text-white bg-[#1a56db] hover:bg-blue-700 rounded-xl transition shadow-sm">
|
||||||
번호 추천받기
|
외주 의뢰하기
|
||||||
</a>
|
</a>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
@@ -680,65 +668,15 @@ export default function MyPage() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 구독 플랜 이동 */}
|
{/* 서비스 이동 */}
|
||||||
<div className="text-center py-2">
|
<div className="text-center py-2">
|
||||||
<a href="/services/lotto" className="text-sm text-slate-400 hover:text-slate-600 transition">
|
<a href="/services/prompt" className="text-sm text-slate-400 hover:text-slate-600 transition">
|
||||||
다른 플랜 보기 →
|
다른 서비스 보기 →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 로또 번호 기록 */}
|
|
||||||
{tab === 'lotto' && (
|
|
||||||
<div>
|
|
||||||
{lottoHistory.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon="🎰"
|
|
||||||
title="생성된 번호 기록이 없습니다"
|
|
||||||
desc="로또 번호 추천 페이지에서 번호를 생성하면 여기에 기록됩니다"
|
|
||||||
linkHref="/services/lotto/recommend"
|
|
||||||
linkLabel="번호 추천받기"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs text-slate-400 mb-1">총 {lottoHistory.length}개 조합 생성</div>
|
|
||||||
{lottoHistory.map((item) => {
|
|
||||||
const info = PLAN_LABELS[item.plan_id];
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="bg-white rounded-2xl border border-[#dbe8ff] px-5 py-4 flex items-center justify-between flex-wrap gap-3">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
|
||||||
{item.numbers.map((n) => {
|
|
||||||
const color =
|
|
||||||
n <= 10 ? 'bg-yellow-400 text-yellow-900' :
|
|
||||||
n <= 20 ? 'bg-blue-500 text-white' :
|
|
||||||
n <= 30 ? 'bg-red-500 text-white' :
|
|
||||||
n <= 40 ? 'bg-slate-500 text-white' :
|
|
||||||
'bg-green-500 text-white';
|
|
||||||
return (
|
|
||||||
<span key={n} className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-xs font-black shadow-sm`}>
|
|
||||||
{n}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${item.source === 'nas' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-slate-100 text-slate-500'}`}>
|
|
||||||
{item.source === 'nas' ? 'NAS 추천' : '로컬 생성'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-amber-600 font-semibold">{info?.emoji} {info?.label}</span>
|
|
||||||
<span className="text-xs text-slate-400">{new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 사주 기록 */}
|
{/* 사주 기록 */}
|
||||||
{tab === 'saju' && (
|
{tab === 'saju' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
230
marketing/kmong-images/01-services-overview.html
Normal file
230
marketing/kmong-images/01-services-overview.html
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 652px; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; background: #0f172a; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
padding: 48px 40px 36px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #1e3a5f;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.hero h1 span { color: #60a5fa; }
|
||||||
|
.hero p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section { padding: 36px 40px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #60a5fa;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.service-card:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.card-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-blue { background: #1e3a5f; }
|
||||||
|
.icon-violet { background: #2d1b69; }
|
||||||
|
.icon-teal { background: #0f3460; }
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.card-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.card-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.tag {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 20px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.footer-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.footer-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
.footer-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="badge">쟁승메이드 · 7년 경력 백엔드 개발자</div>
|
||||||
|
<h1>아이디어를 <span>실제 서비스</span>로<br>직접 만들어 드립니다</h1>
|
||||||
|
<p>웹·앱 개발부터 업무 자동화, AI 프롬프트까지<br>기획-개발-배포 원스톱으로 진행합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">제공 서비스</div>
|
||||||
|
|
||||||
|
<div class="service-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon icon-blue">🌐</div>
|
||||||
|
<div>
|
||||||
|
<div class="card-title">웹 서비스 / MVP 개발</div>
|
||||||
|
<div class="card-sub">홈페이지 · 서비스 플랫폼 · 관리자 대시보드</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
아이디어를 실제로 작동하는 서비스로 만들어 드립니다.<br>
|
||||||
|
기획부터 디자인, 개발, 배포까지 혼자 맡겨도 됩니다.
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span class="tag">Next.js</span>
|
||||||
|
<span class="tag">React</span>
|
||||||
|
<span class="tag">FastAPI</span>
|
||||||
|
<span class="tag">PostgreSQL</span>
|
||||||
|
<span class="tag">Supabase</span>
|
||||||
|
<span class="tag">Vercel 배포</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="service-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon icon-teal">⚙️</div>
|
||||||
|
<div>
|
||||||
|
<div class="card-title">업무 자동화 개발</div>
|
||||||
|
<div class="card-sub">반복 업무 제거 · 엑셀·이메일·크롤링 자동화</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
매일 반복하는 업무를 자동화해 시간을 되돌려 드립니다.<br>
|
||||||
|
주문 수집, 알림 발송, 데이터 정리 등 모두 가능합니다.
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span class="tag">Python</span>
|
||||||
|
<span class="tag">Selenium</span>
|
||||||
|
<span class="tag">Playwright</span>
|
||||||
|
<span class="tag">API 연동</span>
|
||||||
|
<span class="tag">카카오 알림톡</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="service-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon icon-violet">🤖</div>
|
||||||
|
<div>
|
||||||
|
<div class="card-title">AI 프롬프트 엔지니어링</div>
|
||||||
|
<div class="card-sub">업무 특화 AI 활용 · GPT API 연동 챗봇</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
ChatGPT·Claude를 실무에 제대로 활용하도록 설계해 드립니다.<br>
|
||||||
|
업종별 맞춤 프롬프트 패키지와 API 연동 자동화까지 가능합니다.
|
||||||
|
</div>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span class="tag">GPT-4o</span>
|
||||||
|
<span class="tag">Claude</span>
|
||||||
|
<span class="tag">Gemini</span>
|
||||||
|
<span class="tag">API 챗봇</span>
|
||||||
|
<span class="tag">프롬프트 패키지</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="footer-row">
|
||||||
|
<div class="footer-item">
|
||||||
|
<div class="footer-value">7년</div>
|
||||||
|
<div class="footer-label">개발 경력</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-item">
|
||||||
|
<div class="footer-value">직접 개발</div>
|
||||||
|
<div class="footer-label">재하청 없음</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-item">
|
||||||
|
<div class="footer-value">소스코드</div>
|
||||||
|
<div class="footer-label">전체 제공</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-item">
|
||||||
|
<div class="footer-value">실운영</div>
|
||||||
|
<div class="footer-label">포트폴리오</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
187
marketing/kmong-images/02-why-us.html
Normal file
187
marketing/kmong-images/02-why-us.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 652px; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; background: #0f172a; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 40px 40px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hero h1 span { color: #f59e0b; }
|
||||||
|
.hero p { font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
.grid { padding: 8px 40px 40px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.diff-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.diff-num {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1.5px solid #334155;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #60a5fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.diff-body {}
|
||||||
|
.diff-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.diff-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.diff-desc strong { color: #60a5fa; font-weight: 600; }
|
||||||
|
|
||||||
|
.compare {
|
||||||
|
margin: 0 40px 32px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
}
|
||||||
|
.compare-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
background: #0f172a;
|
||||||
|
border-bottom: 1px solid #2d3f55;
|
||||||
|
}
|
||||||
|
.compare-header div {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.compare-header .col-label { color: #475569; }
|
||||||
|
.compare-header .col-bad { color: #f87171; }
|
||||||
|
.compare-header .col-good { color: #34d399; }
|
||||||
|
|
||||||
|
.compare-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
}
|
||||||
|
.compare-row:last-child { border-bottom: none; }
|
||||||
|
.compare-row div {
|
||||||
|
padding: 11px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.col-label { color: #94a3b8; background: #131f2e; }
|
||||||
|
.col-bad { color: #fca5a5; background: #1e293b; }
|
||||||
|
.col-good { color: #6ee7b7; background: #0d2318; }
|
||||||
|
.col-good strong { color: #34d399; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="badge">왜 쟁승메이드인가</div>
|
||||||
|
<h1>외주 개발, 한 번이라도<br><span>실망한 적 있으신가요?</span></h1>
|
||||||
|
<p>자주 들리는 불만을 직접 해소하는 방식으로 운영합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compare">
|
||||||
|
<div class="compare-header">
|
||||||
|
<div class="col-label">항목</div>
|
||||||
|
<div class="col-bad">일반 외주</div>
|
||||||
|
<div class="col-good">쟁승메이드</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<div class="col-label">개발 주체</div>
|
||||||
|
<div class="col-bad">재하청 가능</div>
|
||||||
|
<div class="col-good"><strong>대표가 직접</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<div class="col-label">중간 연락</div>
|
||||||
|
<div class="col-bad">답변 지연 잦음</div>
|
||||||
|
<div class="col-good"><strong>매일 현황 공유</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<div class="col-label">소스코드</div>
|
||||||
|
<div class="col-bad">미제공 / 일부만</div>
|
||||||
|
<div class="col-good"><strong>전체 인도</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<div class="col-label">포트폴리오</div>
|
||||||
|
<div class="col-bad">예시만 제공</div>
|
||||||
|
<div class="col-good"><strong>실운영 서비스</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<div class="col-label">납품 이후</div>
|
||||||
|
<div class="col-bad">연락 두절</div>
|
||||||
|
<div class="col-good"><strong>유지보수 포함</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="diff-card">
|
||||||
|
<div class="diff-num">1</div>
|
||||||
|
<div class="diff-body">
|
||||||
|
<div class="diff-title">직접 개발, 재하청 없음</div>
|
||||||
|
<div class="diff-desc">외주를 또 외주 주는 구조 없이 <strong>제가 직접 개발</strong>합니다. 소통 단절과 품질 저하 문제가 발생하지 않습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diff-card">
|
||||||
|
<div class="diff-num">2</div>
|
||||||
|
<div class="diff-body">
|
||||||
|
<div class="diff-title">실제 운영 서비스 보유</div>
|
||||||
|
<div class="diff-desc">현재 운영 중인 AI 분석 시스템·자동화 솔루션을 포트폴리오로 확인하실 수 있습니다. <strong>"만들어만 주고 끝"이 아닌 운영자의 시각</strong>으로 개발합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diff-card">
|
||||||
|
<div class="diff-num">3</div>
|
||||||
|
<div class="diff-body">
|
||||||
|
<div class="diff-title">소스코드 전체 인도</div>
|
||||||
|
<div class="diff-desc">납품 후 소스코드 전량을 제공합니다. <strong>유지보수 종속 없이</strong> 자유롭게 활용하거나 다른 개발자에게 이어서 맡길 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diff-card">
|
||||||
|
<div class="diff-num">4</div>
|
||||||
|
<div class="diff-body">
|
||||||
|
<div class="diff-title">투명한 일일 커뮤니케이션</div>
|
||||||
|
<div class="diff-desc">작업 진행 현황을 <strong>매일 카카오톡으로 공유</strong>합니다. 중간에 연락이 끊기는 일은 없습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
235
marketing/kmong-images/03-process.html
Normal file
235
marketing/kmong-images/03-process.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 652px; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; background: #0f172a; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 40px 40px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hero p { font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
.steps { padding: 8px 40px 40px; }
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.step-circle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.step-line {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-right {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.step:last-child .step-right { padding-bottom: 0; }
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.step-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.step-desc strong { color: #60a5fa; }
|
||||||
|
|
||||||
|
/* Step colors */
|
||||||
|
.s1 .step-circle { background: #1e3a5f; color: #60a5fa; }
|
||||||
|
.s1 .step-line { background: linear-gradient(#1e3a5f, #1a2d4a); }
|
||||||
|
.s1 .step-label { color: #60a5fa; }
|
||||||
|
|
||||||
|
.s2 .step-circle { background: #1a2d4a; color: #93c5fd; }
|
||||||
|
.s2 .step-line { background: linear-gradient(#1a2d4a, #1e2d42); }
|
||||||
|
.s2 .step-label { color: #93c5fd; }
|
||||||
|
|
||||||
|
.s3 .step-circle { background: #1e2d42; color: #7dd3fc; }
|
||||||
|
.s3 .step-line { background: linear-gradient(#1e2d42, #1a3040); }
|
||||||
|
.s3 .step-label { color: #7dd3fc; }
|
||||||
|
|
||||||
|
.s4 .step-circle { background: #1a3040; color: #34d399; }
|
||||||
|
.s4 .step-line { background: linear-gradient(#1a3040, #0d2318); }
|
||||||
|
.s4 .step-label { color: #34d399; }
|
||||||
|
|
||||||
|
.s5 .step-circle { background: #0d2318; color: #34d399; }
|
||||||
|
.s5 .step-line { background: linear-gradient(#0d2318, #1e293b); }
|
||||||
|
.s5 .step-label { color: #34d399; }
|
||||||
|
|
||||||
|
.s6 .step-circle { background: #1e293b; color: #a78bfa; }
|
||||||
|
.s6 .step-line { background: linear-gradient(#1e293b, #2d1b69); }
|
||||||
|
.s6 .step-label { color: #a78bfa; }
|
||||||
|
|
||||||
|
.s7 .step-circle { background: #2d1b69; color: #c4b5fd; }
|
||||||
|
.s7 .step-label { color: #c4b5fd; }
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0 40px 32px;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.note strong { color: #60a5fa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="badge">진행 절차</div>
|
||||||
|
<h1>문의부터 납품까지<br>7단계로 투명하게 진행합니다</h1>
|
||||||
|
<p>구매 전 상담은 무료입니다 · 착수금 50% → 잔금 50%</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
|
||||||
|
<div class="step s1">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">1</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 01 · 무료</div>
|
||||||
|
<div class="step-title">사전 상담</div>
|
||||||
|
<div class="step-desc">크몽 채팅으로 요구사항을 보내주세요.<br><strong>24시간 내 답변</strong>, 상담은 100% 무료입니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s2">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">2</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 02</div>
|
||||||
|
<div class="step-title">요구사항 분석 및 견적 확정</div>
|
||||||
|
<div class="step-desc">작업 범위·기간·금액을 확정하고 <strong>문서로 공유</strong>합니다.<br>필요 시 화상 미팅(30분) 진행합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s3">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">3</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 03</div>
|
||||||
|
<div class="step-title">계약 및 착수금 결제</div>
|
||||||
|
<div class="step-desc">크몽 시스템으로 결제합니다. (착수금 50%)<br>결제 후 <strong>영업일 1~2일 내 작업 착수</strong>합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s4">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">4</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 04</div>
|
||||||
|
<div class="step-title">개발 진행</div>
|
||||||
|
<div class="step-desc"><strong>매일 진행 현황을 카카오톡으로 공유</strong>합니다.<br>중간 완성본은 스테이징 URL로 확인하실 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s5">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">5</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 05</div>
|
||||||
|
<div class="step-title">중간 검토 및 수정</div>
|
||||||
|
<div class="step-desc">1차 결과물 공유 후 피드백을 반영합니다.<br>패키지 내 <strong>수정 횟수(2~5회)</strong> 내에서 조율합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s6">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">6</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 06</div>
|
||||||
|
<div class="step-title">최종 납품</div>
|
||||||
|
<div class="step-desc"><strong>소스코드 전체 + 배포 URL + 운영 가이드</strong> 제공<br>잔금(50%) 결제 후 최종 인도합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step s7">
|
||||||
|
<div class="step-left">
|
||||||
|
<div class="step-circle">7</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-right">
|
||||||
|
<div class="step-label">STEP 07 · 무상</div>
|
||||||
|
<div class="step-title">유지보수 기간</div>
|
||||||
|
<div class="step-desc">납품 후 <strong>1~3개월</strong> 동안 버그 수정을 무상 지원합니다.<br>기능 추가·변경은 별도 견적으로 진행합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
💡 <strong>착수금 구조</strong>: 착수금 50% 결제 → 작업 착수 → 최종 납품 후 잔금 50% 결제<br>
|
||||||
|
범위 외 추가 요청은 사전 협의 후 별도 견적이 발생할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
233
marketing/kmong-images/04-packages.html
Normal file
233
marketing/kmong-images/04-packages.html
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 652px; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; background: #0f172a; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 40px 40px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hero p { font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
.packages { padding: 8px 28px 32px; display: flex; gap: 12px; }
|
||||||
|
|
||||||
|
.pkg {
|
||||||
|
flex: 1;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1.5px solid #2d3f55;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pkg.featured {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6, 0 8px 24px rgba(59,130,246,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-header {
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #2d3f55;
|
||||||
|
}
|
||||||
|
.pkg-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.badge-basic { background: #1e3a5f; color: #93c5fd; }
|
||||||
|
.badge-std { background: #1d4ed8; color: #fff; }
|
||||||
|
.badge-prem { background: #2d1b69; color: #c4b5fd; }
|
||||||
|
|
||||||
|
.pkg-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.pkg-price {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #60a5fa;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.pkg-price span { font-size: 11px; font-weight: 400; color: #64748b; }
|
||||||
|
.pkg-period {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-body { padding: 14px 14px; }
|
||||||
|
.pkg-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.feature {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.feature .icon { color: #34d399; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.feature .icon-x { color: #475569; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|
||||||
|
.pkg-footer {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid #2d3f55;
|
||||||
|
background: #131f2e;
|
||||||
|
}
|
||||||
|
.meta-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
|
||||||
|
.meta-row:last-child { margin-bottom: 0; }
|
||||||
|
.meta-label { font-size: 10px; color: #475569; }
|
||||||
|
.meta-value { font-size: 10px; font-weight: 600; color: #94a3b8; }
|
||||||
|
.meta-value.hl { color: #60a5fa; }
|
||||||
|
|
||||||
|
.recommend {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
background: #1d4ed8;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0 28px 32px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.note strong { color: #f59e0b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="badge">패키지 구성</div>
|
||||||
|
<h1>프로젝트 규모에 맞게<br>선택하세요</h1>
|
||||||
|
<p>구매 전 채팅 상담으로 맞춤 견적도 가능합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="packages">
|
||||||
|
|
||||||
|
<div class="pkg">
|
||||||
|
<div class="pkg-header">
|
||||||
|
<div class="pkg-badge badge-basic">BASIC</div>
|
||||||
|
<div class="pkg-name">랜딩·소개 페이지</div>
|
||||||
|
<div class="pkg-price">330,000<span>원~</span></div>
|
||||||
|
<div class="pkg-period">작업 기간 7일</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-body">
|
||||||
|
<div class="pkg-desc">단일 페이지 웹사이트 또는 업무 자동화 스크립트 1종</div>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature"><span class="icon">✓</span> 소개·랜딩 페이지 1종</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 반응형 (모바일 포함)</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 소스코드 전체 제공</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 수정 2회 포함</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 유지보수 1개월</div>
|
||||||
|
<div class="feature"><span class="icon-x">✗</span> <span style="color:#475569">배포 지원 미포함</span></div>
|
||||||
|
<div class="feature"><span class="icon-x">✗</span> <span style="color:#475569">DB·로그인 미포함</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-footer">
|
||||||
|
<div class="meta-row"><span class="meta-label">수정</span><span class="meta-value">2회</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">기간</span><span class="meta-value">7일</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">유지보수</span><span class="meta-value">1개월</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pkg featured">
|
||||||
|
<div class="recommend">⭐ 가장 많이 선택</div>
|
||||||
|
<div class="pkg-header">
|
||||||
|
<div class="pkg-badge badge-std">STANDARD</div>
|
||||||
|
<div class="pkg-name">웹 서비스 / MVP</div>
|
||||||
|
<div class="pkg-price">990,000<span>원~</span></div>
|
||||||
|
<div class="pkg-period">작업 기간 21일</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-body">
|
||||||
|
<div class="pkg-desc">회원가입·로그인·CRUD 포함 웹 서비스 또는 자동화 시스템</div>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature"><span class="icon">✓</span> 멀티 페이지 서비스</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 회원가입 · 로그인</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> DB 설계 및 연동</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 배포 지원 포함</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 소스코드 전체 제공</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 수정 3회 포함</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 유지보수 2개월</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-footer">
|
||||||
|
<div class="meta-row"><span class="meta-label">수정</span><span class="meta-value hl">3회</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">기간</span><span class="meta-value hl">21일</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">유지보수</span><span class="meta-value hl">2개월</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pkg">
|
||||||
|
<div class="pkg-header">
|
||||||
|
<div class="pkg-badge badge-prem">PREMIUM</div>
|
||||||
|
<div class="pkg-name">풀스택 서비스</div>
|
||||||
|
<div class="pkg-price">2,200,000<span>원~</span></div>
|
||||||
|
<div class="pkg-period">작업 기간 45일</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-body">
|
||||||
|
<div class="pkg-desc">API 연동·관리자 대시보드 포함 풀스택 서비스 개발</div>
|
||||||
|
<div class="feature-list">
|
||||||
|
<div class="feature"><span class="icon">✓</span> 풀스택 서비스 전체</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 관리자 대시보드</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 외부 API 연동</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 결제 시스템 연동</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 배포 + 서버 구성</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 수정 5회 포함</div>
|
||||||
|
<div class="feature"><span class="icon">✓</span> 유지보수 3개월</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-footer">
|
||||||
|
<div class="meta-row"><span class="meta-label">수정</span><span class="meta-value">5회</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">기간</span><span class="meta-value">45일</span></div>
|
||||||
|
<div class="meta-row"><span class="meta-label">유지보수</span><span class="meta-value">3개월</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
💡 <strong>구매 전 채팅 상담 필수</strong> — 요구사항에 따라 작업 범위와 가격이 달라집니다.<br>
|
||||||
|
위 가격은 기준가이며, 복잡도에 따라 맞춤 견적을 제공합니다. 상담은 무료입니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
marketing/kmong-images/05-faq-cta.html
Normal file
209
marketing/kmong-images/05-faq-cta.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 652px; font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; background: #0f172a; }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 40px 40px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hero p { font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
.faq-list { padding: 8px 40px 32px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.faq-q {
|
||||||
|
padding: 14px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.faq-q-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.faq-q-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f1f5f9;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.faq-a {
|
||||||
|
padding: 0 18px 14px 50px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.faq-a strong { color: #60a5fa; }
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
margin: 0 28px 32px;
|
||||||
|
background: linear-gradient(135deg, #1d4ed8 0%, #4f46e5 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cta h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.cta p {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cta-pills {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border: 1px solid rgba(255,255,255,0.25);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise {
|
||||||
|
margin: 0 28px 32px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.promise-item {
|
||||||
|
flex: 1;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #2d3f55;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.promise-icon { font-size: 22px; margin-bottom: 8px; }
|
||||||
|
.promise-title { font-size: 12px; font-weight: 700; color: #f1f5f9; margin-bottom: 4px; }
|
||||||
|
.promise-desc { font-size: 10.5px; color: #64748b; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="badge">자주 묻는 질문</div>
|
||||||
|
<h1>궁금한 점을<br>먼저 확인해 보세요</h1>
|
||||||
|
<p>추가 질문은 채팅으로 언제든지 문의해 주세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-list">
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">
|
||||||
|
<div class="faq-q-icon">Q</div>
|
||||||
|
<div class="faq-q-text">개발 경험이 없어도 의뢰할 수 있나요?</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-a">네, 가능합니다. 아이디어나 해결하고 싶은 문제만 있으면 충분합니다. <strong>요구사항 정리부터 함께 도와드립니다.</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">
|
||||||
|
<div class="faq-q-icon">Q</div>
|
||||||
|
<div class="faq-q-text">패키지에 없는 기능도 추가할 수 있나요?</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-a">가능합니다. 사전 상담 시 말씀해 주시면 <strong>추가 비용을 포함한 맞춤 견적</strong>을 드립니다. 먼저 채팅으로 문의해 주세요.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">
|
||||||
|
<div class="faq-q-icon">Q</div>
|
||||||
|
<div class="faq-q-text">작업 중 진행 상황을 확인할 수 있나요?</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-a"><strong>매일 카카오톡으로 진행 현황을 공유</strong>해 드립니다. 중간 완성본은 스테이징 URL로 직접 확인하실 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">
|
||||||
|
<div class="faq-q-icon">Q</div>
|
||||||
|
<div class="faq-q-text">납품 후 직접 수정할 수 있나요?</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-a"><strong>소스코드 전체를 인도</strong>해 드리므로 개발 지식이 있으시면 직접 수정하실 수 있습니다. 비개발자분은 유지보수 연장 계약으로 지원받으실 수 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-q">
|
||||||
|
<div class="faq-q-icon">Q</div>
|
||||||
|
<div class="faq-q-text">서버·호스팅 비용은 별도인가요?</div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-a">네, 서버 및 도메인 비용은 의뢰인 부담입니다. 저렴하고 안정적인 호스팅 구성을 추천해 드리며, 초기 설정을 도와드립니다. <strong>(월 1~5만원 수준 구성 가능)</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="promise">
|
||||||
|
<div class="promise-item">
|
||||||
|
<div class="promise-icon">💬</div>
|
||||||
|
<div class="promise-title">24시간 내 답변</div>
|
||||||
|
<div class="promise-desc">상담 문의는 24시간 내에 답변드립니다</div>
|
||||||
|
</div>
|
||||||
|
<div class="promise-item">
|
||||||
|
<div class="promise-icon">📋</div>
|
||||||
|
<div class="promise-title">범위 문서화</div>
|
||||||
|
<div class="promise-desc">확정된 작업 범위를 문서로 공유합니다</div>
|
||||||
|
</div>
|
||||||
|
<div class="promise-item">
|
||||||
|
<div class="promise-icon">🔒</div>
|
||||||
|
<div class="promise-title">비밀 유지</div>
|
||||||
|
<div class="promise-desc">요청 시 NDA 작성 가능합니다</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>지금 바로 무료 상담을<br>시작해 보세요</h2>
|
||||||
|
<p>어떤 서비스든 채팅으로 먼저 물어보세요.<br>24시간 내에 답변, 상담은 무료입니다.</p>
|
||||||
|
<div class="cta-pills">
|
||||||
|
<span class="pill">💻 웹·앱 개발</span>
|
||||||
|
<span class="pill">⚙️ 업무 자동화</span>
|
||||||
|
<span class="pill">🤖 AI 프롬프트</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
405
scripts/test-flow.mjs
Normal file
405
scripts/test-flow.mjs
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* 프로젝트 추적 시스템 E2E 테스트 스크립트
|
||||||
|
* 실행: node scripts/test-flow.mjs
|
||||||
|
*
|
||||||
|
* 테스트 흐름:
|
||||||
|
* 1. 테스트 계정 생성 (Supabase Auth)
|
||||||
|
* 2. 관리자 로그인
|
||||||
|
* 3. 관리자 → 테스트 견적서 생성 + 발송
|
||||||
|
* 4. 테스트 유저 → 견적서 코드로 마이페이지 연결
|
||||||
|
* 5. 관리자 → 기본 7단계 마일스톤 초기화
|
||||||
|
* 6. 관리자 → 단계 진행 (1~3단계 완료)
|
||||||
|
* 7. 테스트 유저 → 마이페이지에서 진행 상황 확인 (브라우저)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
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!';
|
||||||
|
|
||||||
|
// 테스트 계정 정보
|
||||||
|
const TEST_EMAIL = `testuser_${Date.now()}@test-jaengseung.com`;
|
||||||
|
const TEST_PASSWORD = 'Test@2026!';
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// 유틸
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
let _adminCookie = '';
|
||||||
|
|
||||||
|
function log(step, msg, data) {
|
||||||
|
const prefix = `\n[STEP ${step}]`;
|
||||||
|
console.log(`${prefix} ${msg}`);
|
||||||
|
if (data) console.log(' →', JSON.stringify(data, null, 2).slice(0, 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(label, value) {
|
||||||
|
console.log(` ✅ ${label}: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(label, err) {
|
||||||
|
console.error(` ❌ ${label}: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminFetch(path, options = {}) {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: _adminCookie,
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
return { status: res.status, data: json, headers: res.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userFetch(path, accessToken, options = {}) {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: `sb-avickbbhyhlnqbbqfzws-auth-token=${accessToken}`,
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
return { status: res.status, data: json };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 1: 테스트 유저 생성
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step1_createUser() {
|
||||||
|
log(1, '테스트 계정 생성');
|
||||||
|
console.log(` 이메일: ${TEST_EMAIL}`);
|
||||||
|
console.log(` 비밀번호: ${TEST_PASSWORD}`);
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
options: { data: { name: '테스트 고객' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) fail('회원가입', error.message);
|
||||||
|
|
||||||
|
const user = data?.user;
|
||||||
|
if (!user) fail('회원가입', '유저 데이터 없음');
|
||||||
|
|
||||||
|
ok('유저 ID', user.id);
|
||||||
|
ok('이메일 확인 필요 여부', user.email_confirmed_at ? '이미 확인됨' : '미확인 (로컬 테스트는 통과)');
|
||||||
|
|
||||||
|
// 로그인으로 세션 획득
|
||||||
|
const { data: loginData, error: loginErr } = await supabase.auth.signInWithPassword({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginErr) fail('유저 로그인', loginErr.message);
|
||||||
|
|
||||||
|
const session = loginData?.session;
|
||||||
|
if (!session) fail('유저 로그인', '세션 없음 (이메일 인증 필요)');
|
||||||
|
|
||||||
|
ok('Access Token 획득', session.access_token.slice(0, 30) + '...');
|
||||||
|
return { userId: user.id, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 2: 관리자 로그인
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step2_adminLogin() {
|
||||||
|
log(2, '관리자 로그인');
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/admin/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: ADMIN_ID, password: ADMIN_PASSWORD }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) fail('관리자 로그인', `HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
if (!setCookie) fail('관리자 로그인', 'Set-Cookie 헤더 없음');
|
||||||
|
|
||||||
|
_adminCookie = setCookie.split(';')[0]; // admin_token=xxx
|
||||||
|
ok('Admin Cookie', _adminCookie.slice(0, 50) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 3: 견적서 생성
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step3_createQuote() {
|
||||||
|
log(3, '견적서 생성');
|
||||||
|
|
||||||
|
const { status, data } = await adminFetch('/api/admin/quotes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: '테스트 홈페이지 제작 견적서',
|
||||||
|
client_name: '테스트 고객',
|
||||||
|
client_email: TEST_EMAIL,
|
||||||
|
client_phone: '010-1234-5678',
|
||||||
|
valid_until: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item1',
|
||||||
|
name: '기업 홈페이지 제작',
|
||||||
|
description: 'Next.js 기반 반응형 홈페이지',
|
||||||
|
unitPrice: 1500000,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item2',
|
||||||
|
name: 'SEO 최적화',
|
||||||
|
description: '메타태그 + 사이트맵 설정',
|
||||||
|
unitPrice: 300000,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: '테스트 플로우용 견적서입니다. 납기 2주 예정.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 201) fail('견적서 생성', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
const quote = data.quote;
|
||||||
|
ok('견적서 ID', quote.id);
|
||||||
|
ok('public_token', quote.public_token);
|
||||||
|
ok('상태', quote.status);
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 4: 견적서 상태를 'sent'로 변경
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step4_sendQuote(quoteId) {
|
||||||
|
log(4, "견적서 상태 → 'sent' (발송)");
|
||||||
|
|
||||||
|
const { status, data } = await adminFetch(`/api/admin/quotes/${quoteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: 'sent' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) fail('견적서 발송', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
ok('새 상태', data.quote?.status);
|
||||||
|
return data.quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 5: 테스트 유저가 견적서 코드 연결
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step5_linkQuote(publicToken, session) {
|
||||||
|
log(5, '테스트 유저 → 견적서 코드 연결 (마이페이지)');
|
||||||
|
|
||||||
|
// 유저 세션 쿠키로 API 호출
|
||||||
|
const res = await fetch(`${BASE_URL}/api/projects/link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: publicToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(` HTTP ${res.status}:`, JSON.stringify(data));
|
||||||
|
|
||||||
|
if (res.status === 200 && data.success) {
|
||||||
|
ok('연결 성공', `quoteId=${data.quoteId}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// RLS 제한으로 실패할 수 있음 — 안내 메시지 출력
|
||||||
|
console.warn('\n ⚠️ 연결 API가 실패했습니다.');
|
||||||
|
console.warn(' 원인: SUPABASE_SERVICE_ROLE_KEY 가 비어 있어 RLS 우회 불가');
|
||||||
|
console.warn(' 해결: 아래 STEP 5b 안내 따라 서비스 롤 키를 추가하거나,');
|
||||||
|
console.warn(' 브라우저에서 직접 마이페이지 > "견적서 코드 입력" 테스트하세요.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 6: 기본 7단계 마일스톤 초기화
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step6_initMilestones(quoteId) {
|
||||||
|
log(6, '기본 7단계 마일스톤 초기화');
|
||||||
|
|
||||||
|
const { status, data } = await adminFetch('/api/admin/milestones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ useDefaults: true, quoteId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200 && status !== 201) fail('마일스톤 초기화', `HTTP ${status} - ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
ok('마일스톤 생성 수', data.milestones?.length ?? 0);
|
||||||
|
data.milestones?.forEach((m) => console.log(` ${m.step_number}. [${m.status}] ${m.title}`));
|
||||||
|
return data.milestones;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 7: 마일스톤 단계 업데이트
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step7_updateMilestones(milestones) {
|
||||||
|
log(7, '단계 진행 시뮬레이션 (1·2·3단계 완료, 4단계 진행중)');
|
||||||
|
|
||||||
|
const updates = [
|
||||||
|
{ id: milestones[0].id, status: 'completed', note: '고객 의뢰 접수 완료. 2주 일정 확인.' },
|
||||||
|
{ id: milestones[1].id, status: 'completed', note: '계약서 서명 및 선금 50% 입금 완료.' },
|
||||||
|
{ id: milestones[2].id, status: 'completed', note: '와이어프레임 v1 전달. 고객 승인.' },
|
||||||
|
{ id: milestones[3].id, status: 'in_progress', note: '디자인 시안 작업 중 (예상 3일).' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const u of updates) {
|
||||||
|
const { status, data } = await adminFetch(`/api/admin/milestones/${u.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: u.status, note: u.note }),
|
||||||
|
});
|
||||||
|
const m = milestones.find((x) => x.id === u.id);
|
||||||
|
if (status === 200) {
|
||||||
|
ok(`단계 ${m?.step_number} (${m?.title})`, `→ ${u.status}`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ 단계 업데이트 실패: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// STEP 8: 유저 마이페이지 API 검증
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
async function step8_verifyUserView(session) {
|
||||||
|
log(8, '유저 마이페이지 API 확인');
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/projects`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(` HTTP ${res.status}:`, JSON.stringify(data).slice(0, 500));
|
||||||
|
|
||||||
|
if (res.status === 200 && data.projects?.length > 0) {
|
||||||
|
const p = data.projects[0];
|
||||||
|
ok('프로젝트 제목', p.title);
|
||||||
|
ok('상태', p.status);
|
||||||
|
ok('마일스톤 수', p.milestones?.length ?? 0);
|
||||||
|
const done = p.milestones?.filter((m) => m.status === 'completed').length ?? 0;
|
||||||
|
const inProg = p.milestones?.filter((m) => m.status === 'in_progress').length ?? 0;
|
||||||
|
ok('완료 단계', done);
|
||||||
|
ok('진행중 단계', inProg);
|
||||||
|
} else {
|
||||||
|
console.warn(' ⚠️ 프로젝트가 없거나 연결 실패. 브라우저 직접 확인 필요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// 최종 안내 출력
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
function printBrowserGuide(quote, email, password, linked) {
|
||||||
|
console.log('\n' + '═'.repeat(60));
|
||||||
|
console.log('📋 브라우저 확인 가이드');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
[관리자 화면]
|
||||||
|
URL: http://localhost:3000/admin/login
|
||||||
|
ID : jaengseung_admin
|
||||||
|
PW : JaengSeung@Admin2026!
|
||||||
|
|
||||||
|
→ 로그인 후 견적서 목록에서 아래 견적서 클릭:
|
||||||
|
"${quote.title}" (${quote.id.slice(0, 8)}...)
|
||||||
|
→ "진행 단계" 탭에서 마일스톤 상태 확인
|
||||||
|
→ 단계 상태 변경 → 저장 테스트
|
||||||
|
|
||||||
|
[고객(테스트 유저) 화면]
|
||||||
|
URL : http://localhost:3000/login
|
||||||
|
이메일: ${email}
|
||||||
|
비밀번호: ${password}
|
||||||
|
|
||||||
|
→ 로그인 후 마이페이지 → "프로젝트 현황" 탭
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!linked) {
|
||||||
|
console.log(` ⚠️ 자동 연결이 안 된 경우:
|
||||||
|
→ 마이페이지 아래 "견적서 코드 입력" 박스에 아래 코드 입력:
|
||||||
|
${quote.public_token}
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ 견적서가 이미 연결됨. 바로 "프로젝트 현황" 탭 확인 가능.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[확인 포인트]
|
||||||
|
- 진행률 막대 (완료 3/7 → 약 43%)
|
||||||
|
- 현재 진행 단계 "디자인 시안" 강조 표시
|
||||||
|
- 완료 단계에 ✓ 체크 아이콘 + 날짜
|
||||||
|
- 메모 내용 표시
|
||||||
|
|
||||||
|
[추가 테스트]
|
||||||
|
1. 관리자에서 "4단계 완료", "5단계 진행중"으로 변경
|
||||||
|
2. 새로고침 없이 마이페이지 탭 재진입 → 즉시 반영 확인
|
||||||
|
3. 최종 납품 완료(7단계)로 변경 → 상태 배지 변경 확인
|
||||||
|
`);
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
// 메인
|
||||||
|
// ───────────────────────────────────────────
|
||||||
|
(async () => {
|
||||||
|
console.log('\n🚀 쟁승메이드 프로젝트 추적 시스템 E2E 테스트 시작\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 테스트 유저 생성
|
||||||
|
const { userId, session } = await step1_createUser().catch(async () => {
|
||||||
|
// 이미 존재하면 로그인만
|
||||||
|
console.log(' ℹ️ 이미 존재하는 계정으로 로그인 시도...');
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
if (error) fail('유저 로그인', error.message);
|
||||||
|
return { userId: data.user.id, session: data.session };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 관리자 로그인
|
||||||
|
await step2_adminLogin();
|
||||||
|
|
||||||
|
// 3. 견적서 생성
|
||||||
|
const quote = await step3_createQuote();
|
||||||
|
|
||||||
|
// 4. 견적서 발송
|
||||||
|
await step4_sendQuote(quote.id);
|
||||||
|
|
||||||
|
// 5. 유저가 견적서 연결
|
||||||
|
const linked = await step5_linkQuote(quote.public_token, session);
|
||||||
|
|
||||||
|
// 6. 마일스톤 초기화
|
||||||
|
const milestones = await step6_initMilestones(quote.id);
|
||||||
|
|
||||||
|
// 7. 단계 업데이트
|
||||||
|
if (milestones?.length >= 4) {
|
||||||
|
await step7_updateMilestones(milestones);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 유저 뷰 검증
|
||||||
|
await step8_verifyUserView(session);
|
||||||
|
|
||||||
|
// 최종 안내
|
||||||
|
printBrowserGuide(quote, TEST_EMAIL, TEST_PASSWORD, linked);
|
||||||
|
|
||||||
|
console.log('\n✅ 테스트 스크립트 완료!\n');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n❌ 예상치 못한 오류:', err.message ?? err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
42
supabase/migrations/003_fix_quotes_rls.sql
Normal file
42
supabase/migrations/003_fix_quotes_rls.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- quotes 테이블 RLS 수정
|
||||||
|
-- 문제: 이전 마이그레이션이 quotes RLS를 활성화했으나
|
||||||
|
-- 관리자 클라이언트가 service_role 없이 anon 키를 사용하여 INSERT/SELECT 불가
|
||||||
|
--
|
||||||
|
-- 해결: quotes 테이블은 서버 사이드 관리자 코드로만 접근하므로
|
||||||
|
-- RLS 비활성화 (anon 키에 직접 노출되지 않으므로 안전)
|
||||||
|
-- project_milestones는 유저 보안상 RLS 유지
|
||||||
|
--
|
||||||
|
-- Supabase Dashboard > SQL Editor 에서 실행하세요
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- quotes 테이블 RLS 비활성화 (관리자 서버 코드만 접근)
|
||||||
|
ALTER TABLE quotes DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 기존 quotes 정책 삭제 (있다면)
|
||||||
|
DROP POLICY IF EXISTS "Users view own quotes" ON quotes;
|
||||||
|
|
||||||
|
-- quotes 테이블의 모든 RLS 정책 삭제 후 RLS 비활성화
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
pol record;
|
||||||
|
BEGIN
|
||||||
|
FOR pol IN
|
||||||
|
SELECT policyname FROM pg_policies WHERE tablename = 'quotes'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP POLICY IF EXISTS %I ON quotes', pol.policyname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE quotes DISABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT relrowsecurity FROM pg_class WHERE relname = 'quotes';
|
||||||
|
|
||||||
|
|
||||||
|
-- project_milestones: anon 역할도 admin 작업 가능하도록
|
||||||
|
-- (서버 사이드 코드에서만 사용, 클라이언트 직접 접근 없음)
|
||||||
|
CREATE POLICY "Admin manage milestones"
|
||||||
|
ON project_milestones FOR ALL TO anon
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
Reference in New Issue
Block a user