Compare commits

..

36 Commits

Author SHA1 Message Date
a3933c1081 feat(home): 소프트웨어 진열 섹션 products 동적 연동
getListedProducts(createAdminClient()) try/catch로 상위 3개 카드 렌더,
0개이면 기존 출시 준비 중 폴백 유지. force-dynamic으로 항상 최신 목록 노출.
2026-06-12 00:26:03 +09:00
d2a20c5cb7 feat(admin): 제품 관리 — CRUD + 파일 업로드·제품 배정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:20:48 +09:00
e713ebceee docs(plan): Phase 2 재개 체크포인트 — P2-8까지 완료, P2-9 미커밋 중단 2026-06-11 13:21:01 +09:00
dc5e9d431c fix(admin): 주문 취소에도 confirm 확인 추가 2026-06-11 09:17:57 +09:00
7b02e28f6c feat(admin): 주문 관리 페이지 — 입금 확인 원클릭 + 다운로드 활성화 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:13:34 +09:00
8dafb98f47 fix(products): 모달 401 세션만료 처리 + callback open redirect 방어 + 초기 포커스
- BankTransferModal: POST /api/orders 401 응답 시 setAuthState('guest')로 전환 (에러 텍스트 대신 로그인 유도 UI 복귀)
- BankTransferModal: 모달 열릴 때 closeBtnRef.current?.focus() 호출 (접근성 초기 포커스)
- auth/callback: next 파라미터를 safeNext 패턴으로 검증 — startsWith('/') && !startsWith('//') && !startsWith('/\') 미충족 시 /mypage 폴백

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:11:05 +09:00
199dae0ee5 feat(products): 동적 카탈로그·상세 페이지 + 계좌이체 구매 모달
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:07:00 +09:00
f151af89f3 feat(downloads): 다운로드 검증을 orders 단일 소스로 교체 + 내 제품 제품별 그룹핑
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:47:32 +09:00
3fa865a6e7 fix(orders): user 기준 rate limit + 상품 조회 예외 처리
- checkRateLimit('orders:{user.id}', 60_000, 5) 인증 직후 적용 → 429 반환
- getProductById try/catch 추가 → DB 장애 시 500 '상품 조회에 실패했습니다'
- lib/order-emails.ts sendOrderPaidEmail HTML 이스케이프 대상 없음 (해당 없음)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:41:42 +09:00
1d5e7254ed feat(orders): 계좌이체 주문 생성 API + 접수/입금확인 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:36:22 +09:00
692fb504d9 feat(products): orders 기반 제품/파일 조회 헬퍼 2026-06-11 08:32:44 +09:00
e86ca27831 feat(db): 음악 팩 구매 이력 contact_requests → orders 멱등 이관 2026-06-11 08:28:34 +09:00
5d90ac310e feat(db): products 카탈로그 확장 + 음악 제품 시드 + pack_files.product_id 백필 2026-06-11 08:28:31 +09:00
cf89e8cbdb feat(products): vitest 도입 + 제품 접근 확장 로직 (music tier 하위 호환)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:24:33 +09:00
fe055fd0d0 docs(plan): 리뉴얼 Phase 2 구현 계획 — orders 단일 소스 제품 판매 시스템
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:21:39 +09:00
gahusb
0580fe8f5a Merge pull request #1 from gahusb/feature/renewal-phase1
리뉴얼 Phase 1: 외주+소프트웨어 2축 풀 리디자인 + 레거시 서비스 숨김
2026-06-11 03:13:35 +09:00
a25b645933 fix(renewal): mypage 숨김 링크 교체 + /products 스텁 + sitemap 정리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 03:04:06 +09:00
c447294c84 fix(quote): 보라 잔재·navy 리터럴을 jsm 토큰으로 정리 2026-06-11 02:49:19 +09:00
c2d7455f65 refactor(design): 노출 페이지 잔여 글래스·다크 스타일 정리
- layout.tsx: GlassFilter 렌더·import 제거 (LiquidGlass.tsx 파일은 숨김 페이지용으로 유지)
- payment/success,fail: #04102b 다크 헤더 → jsm-navy 토큰, h2 색상 jsm-ink 토큰으로 교체
- legal/terms,privacy,refund: h1 text-[#04102b] → jsm-ink CSS 변수
- portfolio/[token]: bg-slate-950 + 다크 radial-gradient → jsm-bg/surface/navy 라이트 테마
- quote/[token]: #0a0f1e/#0f172a 전체 다크 테마 → jsm-* 라이트 토큰 기반으로 전면 교체 (print CSS·수락/거절 로직 무수정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:44:36 +09:00
4bd5400406 feat(mypage): 4탭 재구성 + 전문 톤 리디자인 (데이터·다운로드 로직 무수정)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:31:40 +09:00
76994c28f1 fix(login): 포커스 링·type=button·disabled 대비 접근성 보완
- 이메일/비밀번호 input에 focus-visible:ring-2 추가
- Google 로그인 버튼에 type=\"button\" 명시
- disabled 상태 배경 jsm-line → jsm-ink-faint로 변경 (대비 개선)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:18:37 +09:00
cd1f67d076 feat(login): 로그인 페이지 전문 톤 리디자인 (인증 로직 무수정)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:14:43 +09:00
e0b6120bb6 fix(outsourcing): 착수금 표기를 기존 공표 조건(30%)으로 정정 2026-06-11 02:12:29 +09:00
a11006fab5 feat(outsourcing): 외주 의뢰 페이지 신설 + work 라우트 리다이렉트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:04:38 +09:00
b846a713c1 fix(seo): jsonLd Service URL을 /outsourcing으로 정렬
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:56:58 +09:00
be3cc3752e feat(home): 외주+소프트웨어 2축 메인 페이지 풀 리디자인 + 메타데이터 교체 2026-06-11 01:53:08 +09:00
89dc5364d1 fix(nav): 모바일 드로어 접근성 (aria-expanded·Esc·dialog role) + hover 클래스화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:48:24 +09:00
6d6d6f353a feat(nav): 외주·소프트웨어 2축 네비게이션 + 푸터 리뉴얼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:42:37 +09:00
b13ddd3841 feat(visibility): 사주·음악·설문·패키지 라우트 숨김 가드 적용 2026-06-11 01:35:46 +09:00
281edd9a52 fix(visibility): 기존 시드 행도 숨김 갱신되도록 DO UPDATE + 재사용 경고 JSDoc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:29:23 +09:00
f6df890297 feat(visibility): service_settings 기반 서비스 숨김 가드 + 레거시 서비스 시드
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:24:07 +09:00
776985eca8 fix(design): 미정의 --font-pretendard 변수 간접참조 제거
var(--font-pretendard, ...) 패턴은 --font-pretendard가 어디에도
정의되지 않아 항상 fallback으로 동작합니다. npm CSS import 방식에서
next/font 제거 후 변수 주입이 안되므로 직접 'Pretendard Variable'로
단순화하여 의도 명확화합니다. 모든 fallback stack은 유지됩니다.

변경 위치:
- @theme inline (font-sans, font-mono)
- body (전역 폰트)
- .font-mono 유틸리티
- .kx-section, .kx-display, .kx-label (Kinetic Ether)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:22:13 +09:00
e14e527e28 feat(design): JSM 전문 토큰 체계 + Pretendard 도입, kx 토큰 재매핑
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:15:11 +09:00
a496c2244b docs(plan): 리뉴얼 Phase 1 구현 계획 — 디자인 토큰·숨김 가드·메인/외주/로그인/마이페이지
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:06:48 +09:00
d46acc43e3 docs(spec): 사이트 리뉴얼 설계 — 외주+소프트웨어 판매 2축 재구성
- 계좌이체 중심 결제, 기존 서비스 숨김(admin 토글), 고객 포털, 풀 리디자인
- orders 단일 소스 구매 식별, pack 인프라 범용 제품 시스템 확장

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:59:36 +09:00
3e0d8bcf88 docs(plan): Phase 6 ① 완료 + 재개 체크포인트(③ DNS 전환 직전) 기록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:16:21 +09:00
53 changed files with 7364 additions and 1987 deletions

View File

@@ -36,6 +36,26 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/orders',
label: '주문 관리',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
},
{
href: '/admin/products',
label: '제품 관리',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
href: '/admin/contacts',
label: '문의 내역',

222
app/admin/orders/page.tsx Normal file
View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useState } from 'react';
interface Order {
id: string;
user_id: string | null;
product_id: string | null;
amount: number;
status: 'pending' | 'paid' | 'cancelled';
metadata: Record<string, unknown> | null;
created_at: string;
product_name: string | null;
customer_email: string | null;
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
cancelled: { label: '취소', color: 'bg-slate-700/60 text-slate-500' },
};
const FILTER_TABS = [
{ val: 'all', label: '전체' },
{ val: 'pending', label: '입금 대기' },
{ val: 'paid', label: '완료' },
{ val: 'cancelled', label: '취소' },
] as const;
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all');
const [updating, setUpdating] = useState<string | null>(null);
async function loadOrders() {
try {
const res = await fetch('/api/admin/orders');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json();
setOrders(d.orders ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : '불러오기 실패');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadOrders();
}, []);
async function updateStatus(id: string, status: 'paid' | 'cancelled' | 'pending') {
if (status === 'paid') {
const ok = confirm('입금을 확인하셨습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
if (!ok) return;
}
if (status === 'cancelled') {
const ok = confirm('이 주문을 취소하시겠습니까?');
if (!ok) return;
}
setUpdating(id);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) => (o.id === id ? { ...o, status } : o))
);
} else {
alert('상태 변경에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
const pendingCount = orders.filter((o) => o.status === 'pending').length;
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
{pendingCount > 0 && (
<span className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-3 py-1 rounded-full text-sm font-medium">
{pendingCount}
</span>
)}
</div>
{/* 필터 탭 */}
<div className="flex gap-2 mb-4">
{FILTER_TABS.map(({ val, label }) => (
<button
key={val}
onClick={() => setFilterStatus(val)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filterStatus === val
? 'bg-red-600/30 text-red-300 border border-red-500/30'
: 'bg-slate-800 text-slate-400 hover:text-white'
}`}
>
{label}
{val !== 'all' && (
<span className="ml-1.5 text-xs opacity-70">
{orders.filter((o) => o.status === val).length}
</span>
)}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="bg-red-900/20 border border-red-500/30 rounded-2xl p-10 text-center">
<p className="text-red-400 font-medium">{error}</p>
<button
onClick={() => { setLoading(true); setError(null); loadOrders(); }}
className="mt-3 text-sm text-slate-400 hover:text-white transition"
>
</button>
</div>
) : filtered.length === 0 ? (
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
</div>
) : (
<div className="space-y-2">
{filtered.map((order) => {
const depositorName =
typeof order.metadata?.depositor_name === 'string'
? order.metadata.depositor_name
: null;
return (
<div
key={order.id}
className={`bg-slate-900 rounded-xl p-4 border transition-all ${
order.status === 'cancelled'
? 'border-slate-800/50 opacity-50'
: 'border-slate-700/50 hover:border-slate-600'
}`}
>
<div className="flex items-center gap-4">
{/* 상품명 + 이메일 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-white font-medium text-sm truncate">
{order.product_name ?? '(상품 없음)'}
</span>
<span className="text-blue-400 font-semibold text-sm flex-shrink-0">
{order.amount.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className="truncate">
{order.customer_email ?? order.user_id ?? '이메일 없음'}
</span>
{depositorName && (
<span className="flex-shrink-0 bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full">
: {depositorName}
</span>
)}
<span className="flex-shrink-0">
{new Date(order.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
</div>
{/* 상태 뱃지 + 액션 버튼 */}
<div className="flex items-center gap-2 flex-shrink-0">
{order.status === 'paid' ? (
<span className="text-green-400 text-xs font-medium"> </span>
) : null}
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${STATUS_LABELS[order.status]?.color}`}
>
{STATUS_LABELS[order.status]?.label}
</span>
{order.status === 'pending' && (
<>
<button
onClick={() => updateStatus(order.id, 'paid')}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
>
{updating === order.id ? '처리중...' : '입금 확인'}
</button>
<button
onClick={() => updateStatus(order.id, 'cancelled')}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-400 hover:bg-slate-600 hover:text-white transition disabled:opacity-50"
>
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
type PackTier = 'starter' | 'pro' | 'master';
@@ -155,6 +156,10 @@ export default function AdminPacksPage() {
<p className="text-slate-400 text-sm mt-0.5">
NAS + . 5GB / 4 .
</p>
<p className="text-amber-400/90 text-xs mt-2">
. {' '}
<Link href="/admin/products" className="underline hover:text-amber-300"> </Link> .
</p>
</div>
{/* 업로드 폼 */}

560
app/admin/products/page.tsx Normal file
View File

@@ -0,0 +1,560 @@
'use client';
import { useEffect, useState } from 'react';
interface Product {
id: string;
name: string;
description: string | null;
description_long: string | null;
price: number;
features: string[] | null;
is_listed: boolean;
is_active: boolean;
sort_order: number;
}
interface PackFile {
id: string;
product_id: string | null;
label: string;
filename: string;
size_bytes: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
const EMPTY_FORM = {
id: '',
name: '',
price: 0,
description: '',
description_long: '',
featuresText: '',
is_listed: false,
sort_order: 0,
};
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [files, setFiles] = useState<PackFile[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// 폼 상태
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); // null = 신규
const [form, setForm] = useState({ ...EMPTY_FORM });
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 파일 관리 선택 제품
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
// 업로드 상태
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadLabel, setUploadLabel] = useState('');
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
async function loadAll() {
setLoading(true);
setLoadError(null);
try {
const [pRes, fRes] = await Promise.all([
fetch('/api/admin/products'),
fetch('/api/admin/packs'),
]);
const pData = await pRes.json();
const fData = await fRes.json();
if (!pRes.ok) throw new Error(pData.error ?? '제품 로드 실패');
setProducts(pData.products ?? []);
setFiles(fData.files ?? []);
} catch (e) {
setLoadError(e instanceof Error ? e.message : '로드 실패');
} finally {
setLoading(false);
}
}
// 파일 목록만 재조회 후 반환 (자동 배정 매칭용)
async function reloadFiles(): Promise<PackFile[]> {
const res = await fetch('/api/admin/packs');
const data = await res.json();
const list: PackFile[] = data.files ?? [];
setFiles(list);
return list;
}
useEffect(() => { loadAll(); }, []);
function openNew() {
setEditingId(null);
setForm({ ...EMPTY_FORM });
setFormError(null);
setShowForm(true);
}
function openEdit(p: Product) {
setEditingId(p.id);
setForm({
id: p.id,
name: p.name,
price: p.price,
description: p.description ?? '',
description_long: p.description_long ?? '',
featuresText: (p.features ?? []).join('\n'),
is_listed: p.is_listed,
sort_order: p.sort_order,
});
setFormError(null);
setShowForm(true);
}
async function submitForm(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
setSaving(true);
try {
const features = form.featuresText
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
const payload = {
id: form.id,
name: form.name,
price: Number(form.price),
description: form.description,
description_long: form.description_long,
features,
is_listed: form.is_listed,
sort_order: Number(form.sort_order),
};
const method = editingId ? 'PATCH' : 'POST';
const res = await fetch('/api/admin/products', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '저장 실패');
setShowForm(false);
await loadAll();
} catch (e) {
setFormError(e instanceof Error ? e.message : '저장 실패');
} finally {
setSaving(false);
}
}
async function toggleListed(p: Product) {
try {
await fetch('/api/admin/products', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, is_listed: !p.is_listed }),
});
await loadAll();
} catch (e) {
console.error(e);
}
}
async function patchFileProduct(fileId: string, productId: string | null) {
await fetch('/api/admin/packs', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: fileId, product_id: productId }),
});
await reloadFiles();
}
async function handleUpload(e: React.FormEvent) {
e.preventDefault();
setUploadError(null);
setUploadMsg(null);
if (!uploadFile || !uploadLabel || !selectedProductId) return;
setUploading(true);
setProgress(0);
const targetName = uploadFile.name;
const targetSize = uploadFile.size;
try {
// 1) 토큰 발급 (tier는 starter 고정)
const tokenRes = await fetch('/api/admin/packs/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier: 'starter',
label: uploadLabel,
filename: uploadFile.name,
sizeBytes: uploadFile.size,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
throw new Error(err.error ?? '토큰 발급 실패');
}
const { token, uploadUrl } = await tokenRes.json();
// 2) 브라우저가 web-backend에 직접 multipart POST (XHR 진행률)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
setProgress(Math.round((ev.loaded / ev.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else {
try {
const { detail } = JSON.parse(xhr.responseText);
reject(new Error(detail ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
const fd = new FormData();
fd.append('file', uploadFile);
xhr.send(fd);
});
// 3) 방금 생성된 행을 filename+size로 찾아 자동 배정
const fresh = await reloadFiles();
const candidates = fresh.filter(
(f) => f.filename === targetName && f.size_bytes === targetSize && f.product_id === null,
);
if (candidates.length === 1) {
await patchFileProduct(candidates[0].id, selectedProductId);
setUploadMsg('업로드 + 제품 배정 완료');
} else {
setUploadMsg(
'업로드 완료. 자동 배정에 실패했습니다(동명 파일 등). 아래 미배정 목록에서 수동으로 배정하세요.',
);
}
setUploadFile(null);
setUploadLabel('');
setProgress(0);
} catch (e) {
setUploadError(e instanceof Error ? e.message : '업로드 실패');
} finally {
setUploading(false);
}
}
const selectedProduct = products.find((p) => p.id === selectedProductId) ?? null;
const productFiles = selectedProductId
? files.filter((f) => f.product_id === selectedProductId)
: [];
const otherFiles = selectedProductId
? files.filter((f) => f.product_id !== selectedProductId)
: [];
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
· · .
</p>
</div>
<button
onClick={openNew}
className="bg-violet-600 hover:bg-violet-500 text-white font-bold px-4 py-2 rounded"
>
+
</button>
</div>
{/* 폼 */}
{showForm && (
<form onSubmit={submitForm} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
<h2 className="text-white font-bold mb-4">{editingId ? `제품 편집: ${editingId}` : '새 제품 등록'}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<label className="text-slate-400 text-xs block mb-1"> id (//_)</label>
<input
type="text"
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
disabled={!!editingId || saving}
placeholder="예: lotto_pro"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 disabled:opacity-60"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"></label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"> (, )</label>
<input
type="number"
min={0}
value={form.price}
onChange={(e) => setForm({ ...form, price: Number(e.target.value) })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"> </label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> (1)</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> </label>
<textarea
value={form.description_long}
onChange={(e) => setForm({ ...form, description_long: e.target.value })}
disabled={saving}
rows={3}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> ( )</label>
<textarea
value={form.featuresText}
onChange={(e) => setForm({ ...form, featuresText: e.target.value })}
disabled={saving}
rows={3}
placeholder={'텔레그램 연동\n실시간 알림\n백테스트'}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<label className="flex items-center gap-2 mb-4 text-slate-300 text-sm">
<input
type="checkbox"
checked={form.is_listed}
onChange={(e) => setForm({ ...form, is_listed: e.target.checked })}
disabled={saving}
/>
(is_listed)
</label>
{formError && <p className="text-red-400 text-sm mb-3">{formError}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 text-white font-bold px-5 py-2 rounded"
>
{saving ? '저장 중...' : editingId ? '수정 저장' : '제품 생성'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
disabled={saving}
className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded"
>
</button>
</div>
</form>
)}
{/* 제품 목록 */}
{loading ? (
<p className="text-slate-400"> ...</p>
) : loadError ? (
<p className="text-red-400">{loadError}</p>
) : products.length === 0 ? (
<p className="text-slate-500"> . [+ ] .</p>
) : (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id} className="border-t border-slate-800">
<td className="px-4 py-3 text-white">
{p.name}
<span className="text-slate-500 text-xs ml-2">{p.id}</span>
</td>
<td className="px-4 py-3 text-right text-slate-300">{p.price.toLocaleString()}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleListed(p)}
className={`px-2 py-1 rounded text-xs font-medium ${
p.is_listed
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
: 'bg-slate-700 text-slate-400'
}`}
>
{p.is_listed ? '노출' : '숨김'}
</button>
</td>
<td className="px-4 py-3 text-center text-slate-400">{p.sort_order}</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button
onClick={() => openEdit(p)}
className="text-violet-400 hover:text-violet-300 px-2"
>
</button>
<button
onClick={() => { setSelectedProductId(p.id); setUploadMsg(null); setUploadError(null); }}
className="text-blue-400 hover:text-blue-300 px-2"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 파일 관리 섹션 */}
{selectedProduct && (
<div className="bg-slate-900 border border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-white font-bold">
{selectedProduct.name}
</h2>
<button
onClick={() => setSelectedProductId(null)}
className="text-slate-400 hover:text-white text-sm"
>
</button>
</div>
{/* 현재 제품 파일 */}
<h3 className="text-slate-300 font-semibold text-sm mb-2"> ({productFiles.length})</h3>
{productFiles.length === 0 ? (
<p className="text-slate-500 text-sm mb-4"> .</p>
) : (
<div className="space-y-2 mb-4">
{productFiles.map((f) => (
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
<span className="flex-1 text-white">{f.label}</span>
<span className="text-slate-400 text-xs">{f.filename}</span>
<span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
<button
onClick={() => patchFileProduct(f.id, null)}
className="text-red-400 hover:text-red-300 text-sm px-2"
>
</button>
</div>
))}
</div>
)}
{/* 업로드 */}
<form onSubmit={handleUpload} className="border-t border-slate-800 pt-4 mb-4">
<h3 className="text-slate-300 font-semibold text-sm mb-2"> </h3>
<input
type="text"
value={uploadLabel}
onChange={(e) => setUploadLabel(e.target.value)}
disabled={uploading}
placeholder="파일 라벨 (예: 설치 가이드 PDF)"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 mb-3"
/>
<input
type="file"
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
disabled={uploading}
className="text-slate-300 mb-3 block"
/>
{uploadFile && (
<p className="text-slate-400 text-xs mb-3">
: {uploadFile.name} ({formatSize(uploadFile.size)})
</p>
)}
{uploading && (
<div className="mb-3">
<div className="bg-slate-800 rounded h-2 overflow-hidden">
<div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-slate-400 text-xs mt-1">{progress}% ... </p>
</div>
)}
{uploadMsg && <p className="text-emerald-400 text-sm mb-3">{uploadMsg}</p>}
{uploadError && <p className="text-red-400 text-sm mb-3">{uploadError}</p>}
<button
type="submit"
disabled={uploading || !uploadFile || !uploadLabel}
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
>
{uploading ? '업로드 중...' : '업로드 + 자동 배정'}
</button>
</form>
{/* 미배정/타제품 파일 배정 */}
<div className="border-t border-slate-800 pt-4">
<h3 className="text-slate-300 font-semibold text-sm mb-2"> ({otherFiles.length})</h3>
{otherFiles.length === 0 ? (
<p className="text-slate-500 text-sm"> .</p>
) : (
<div className="space-y-2">
{otherFiles.map((f) => (
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
<span className="flex-1 text-white">{f.label}</span>
<span className="text-slate-400 text-xs">{f.filename}</span>
<span className="text-slate-500 text-xs">
{f.product_id ? `현재: ${f.product_id}` : '미배정'}
</span>
<button
onClick={() => patchFileProduct(f.id, selectedProduct.id)}
className="text-blue-400 hover:text-blue-300 text-sm px-2"
>
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { getProductById } from '@/lib/supabase/product-files';
import { sendOrderPaidEmail } from '@/lib/order-emails';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// GET: 주문 목록 (최근 200건) — 상품명 + 주문자 이메일 포함
export async function GET() {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = createAdminClient();
// 2-쿼리 방식: FK 관계 중첩 select 대신 명시적 조인으로 안전하게
const { data: orders, error } = await supabase
.from('orders')
.select('id, user_id, product_id, amount, status, metadata, created_at')
.order('created_at', { ascending: false })
.limit(200);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
if (!orders || orders.length === 0) {
return NextResponse.json({ orders: [] });
}
// 상품명 조회
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
const [productsRes, profilesRes] = await Promise.all([
productIds.length > 0
? supabase.from('products').select('id, name').in('id', productIds)
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
userIds.length > 0
? supabase.from('profiles').select('id, email').in('id', userIds)
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
]);
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
const enriched = orders.map((o) => ({
...o,
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
}));
return NextResponse.json({ orders: enriched });
}
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일)
export async function PATCH(request: Request) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, status } = await request.json();
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
}
const supabase = createAdminClient();
const { data: order, error } = await supabase
.from('orders')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select('id, product_id, user_id')
.single();
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
if (status === 'paid' && order.product_id && order.user_id) {
try {
const product = await getProductById(supabase, order.product_id);
const { data: profile } = await supabase
.from('profiles')
.select('email')
.eq('id', order.user_id)
.maybeSingle();
if (product && profile?.email) {
await sendOrderPaidEmail({ product, customerEmail: profile.email });
}
} catch (e) {
console.error('paid email failed', e);
}
}
return NextResponse.json({ success: true });
}

View File

@@ -34,7 +34,7 @@ export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id, label, sort_order, min_tier } = await request.json();
const { id, label, sort_order, min_tier, product_id } = await request.json();
if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });
const updates: Record<string, unknown> = {};
@@ -43,6 +43,7 @@ export async function PATCH(request: Request) {
if (typeof min_tier === 'string' && VALID_TIERS.has(min_tier as PackTier)) {
updates.min_tier = min_tier;
}
if (typeof product_id === 'string' || product_id === null) updates.product_id = product_id;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
}

View File

@@ -0,0 +1,111 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
const ID_RE = /^[a-z0-9_]{2,40}$/;
function sanitizeFeatures(input: unknown): string[] | undefined {
if (!Array.isArray(input)) return undefined;
return input.filter((v): v is string => typeof v === 'string');
}
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('products')
.select('*')
.order('sort_order')
.order('id');
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ products: data ?? [] });
}
export async function POST(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id, name, description, description_long, price, features, is_listed, sort_order } = body;
if (typeof id !== 'string' || !ID_RE.test(id)) {
return NextResponse.json({ error: 'id는 영소문자/숫자/언더스코어 2-40자' }, { status: 400 });
}
if (typeof name !== 'string' || name.trim().length === 0) {
return NextResponse.json({ error: 'name 필요' }, { status: 400 });
}
if (typeof price !== 'number' || !Number.isInteger(price) || price < 0) {
return NextResponse.json({ error: 'price는 0 이상의 정수' }, { status: 400 });
}
const insert: Record<string, unknown> = {
id,
name: name.trim(),
price,
category: 'software',
pay_method: 'bank_transfer',
is_active: true,
};
if (typeof description === 'string') insert.description = description;
if (typeof description_long === 'string') insert.description_long = description_long;
const feats = sanitizeFeatures(features);
if (feats !== undefined) insert.features = feats;
if (typeof is_listed === 'boolean') insert.is_listed = is_listed;
if (typeof sort_order === 'number') insert.sort_order = sort_order;
const supabase = createAdminClient();
const { data, error } = await supabase.from('products').insert(insert).select().single();
if (error) {
if (error.code === '23505') {
return NextResponse.json({ error: '이미 존재하는 제품 id' }, { status: 409 });
}
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ product: data });
}
export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id } = body;
if (typeof id !== 'string' || !id) {
return NextResponse.json({ error: 'id 필요' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
if (typeof body.name === 'string') updates.name = body.name.trim();
if (typeof body.description === 'string') updates.description = body.description;
if (typeof body.description_long === 'string') updates.description_long = body.description_long;
if (typeof body.price === 'number' && Number.isInteger(body.price) && body.price >= 0) {
updates.price = body.price;
}
const feats = sanitizeFeatures(body.features);
if (feats !== undefined) updates.features = feats;
if (typeof body.is_listed === 'boolean') updates.is_listed = body.is_listed;
if (typeof body.is_active === 'boolean') updates.is_active = body.is_active;
if (typeof body.sort_order === 'number') updates.sort_order = body.sort_order;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
}
const supabase = createAdminClient();
const { error } = await supabase.from('products').update(updates).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}

View File

@@ -51,10 +51,9 @@ export async function PATCH(request: Request) {
}
const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 서비스', is_active: true, order_index: 1 },
{ id: 'lotto', name: '로또 번호 추천', description: '빅데이터 기반 로또 번호 분석', is_active: true, order_index: 2 },
{ id: 'stock', name: '주식 자동매매', description: '텔레그램 연동 자동매매 프로그램', is_active: true, order_index: 3 },
{ id: 'automation', name: '업무 자동화 RPA', description: '반복 업무 자동화 개발', is_active: true, order_index: 4 },
{ id: 'prompt', name: '프롬프트 엔지니어링', description: 'AI 프롬프트 설계 서비스', is_active: true, order_index: 5 },
{ id: 'freelance', name: '외주 개발', description: '맞춤형 소프트웨어 개발', is_active: true, order_index: 6 },
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
];

123
app/api/orders/route.ts Normal file
View File

@@ -0,0 +1,123 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
import { sanitizeStr, checkRateLimit } from '@/lib/security';
import { sendOrderReceivedEmails } from '@/lib/order-emails';
export const runtime = 'nodejs';
export async function POST(request: Request) {
// 1) 인증 확인 (SSR 쿠키 클라이언트)
const cookieStore = await cookies();
const supabase = createSSRClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: () => {},
},
},
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
// 1-b) Rate Limit: user 기준 분당 5회
const rl = checkRateLimit(`orders:${user.id}`, 60_000, 5);
if (!rl.allowed) {
return NextResponse.json(
{ error: '요청이 너무 잦습니다. 잠시 후 다시 시도해주세요' },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
},
);
}
// 2) body 검증
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청입니다' }, { status: 400 });
}
const rawProductId = (body as Record<string, unknown>).productId;
const rawDepositorName = (body as Record<string, unknown>).depositorName;
const productId = sanitizeStr(rawProductId, 64);
const depositorName = sanitizeStr(rawDepositorName, 40);
if (!productId || !depositorName) {
return NextResponse.json({ error: 'productId와 depositorName이 필요합니다' }, { status: 400 });
}
// 3) 상품 조회 및 활성 상태 확인
const admin = createAdminClient();
let product;
try {
product = await getProductById(admin, productId);
} catch (dbErr) {
console.error('[Orders] product lookup error:', dbErr);
return NextResponse.json({ error: '상품 조회에 실패했습니다' }, { status: 500 });
}
if (!product || !product.is_active) {
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
}
// 4) 중복 pending 방지
const { data: existing } = await admin
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', productId)
.eq('status', 'pending')
.maybeSingle();
if (existing) {
return NextResponse.json({ orderId: existing.id, reused: true });
}
// 5) 주문 생성 (가격은 DB 소스)
const { data: order, error: insertError } = await admin
.from('orders')
.insert({
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: {
method: 'bank_transfer',
depositor_name: depositorName,
},
})
.select('id')
.single();
if (insertError || !order) {
console.error('[Orders] insert error:', insertError);
return NextResponse.json({ error: '주문 생성에 실패했습니다' }, { status: 500 });
}
const orderId = order.id as string;
// 6) 메일 발송 (실패해도 주문 유효)
try {
await sendOrderReceivedEmails({
orderId,
product,
customerEmail: user.email ?? '',
depositorName,
});
} catch (mailError) {
console.error('[Orders] email send error:', mailError);
}
// 7) 응답
return NextResponse.json({ orderId });
}

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';
import { getUserAccessibleProductIds, getFilesByProductIds } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
@@ -20,21 +19,25 @@ export async function GET() {
},
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ files: [] });
if (!user) return NextResponse.json({ products: [] });
const admin = createAdminClient();
const { data: orders } = await admin
.from('contact_requests')
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const productIds = await getUserAccessibleProductIds(admin, user.id);
if (productIds.length === 0) return NextResponse.json({ products: [] });
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
const [files, { data: products }] = await Promise.all([
getFilesByProductIds(admin, productIds),
admin.from('products').select('id, name').in('id', productIds),
]);
const nameMap = new Map((products ?? []).map((p) => [p.id, p.name as string]));
const grouped = new Map<string, { id: string; name: string; files: typeof files }>();
for (const f of files) {
if (!f.product_id) continue;
if (!grouped.has(f.product_id)) {
grouped.set(f.product_id, { id: f.product_id, name: nameMap.get(f.product_id) ?? f.product_id, files: [] });
}
grouped.get(f.product_id)!.files.push(f);
}
const files = await getPackFilesForTiers(admin, Array.from(tiers));
return NextResponse.json({ files });
return NextResponse.json({ products: Array.from(grouped.values()) });
}

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFileById } from '@/lib/supabase/pack-files';
import { getUserAccessibleProductIds, getFileById } from '@/lib/supabase/product-files';
import { signLink } from '@/lib/web-backend';
export const runtime = 'nodejs';
@@ -33,33 +32,18 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2) orders 조회 — completed Music 팩 구매 확인
// 2) orders(paid) 단일 소스로 접근 가능한 product_id 확인
const admin = createAdminClient();
const { data: orders } = await admin
.from('contact_requests')
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
const accessible = await getUserAccessibleProductIds(admin, user.id);
if (accessible.length === 0) {
return NextResponse.json({ error: '구매 내역이 없거나 입금 확인 전입니다' }, { status: 403 });
}
if (tiers.size === 0) {
return NextResponse.json({ error: '구매 내역이 없거나 결제 미완료입니다' }, { status: 403 });
const file = await getFileById(admin, fileId);
if (!file || file.deleted_at || !file.product_id || !accessible.includes(file.product_id)) {
return NextResponse.json({ error: '구매한 제품의 파일이 아닙니다' }, { status: 403 });
}
// 3) 파일 조회 + tier 매칭
const file = await getPackFileById(admin, fileId);
if (!file) {
return NextResponse.json({ error: '파일을 찾을 수 없습니다' }, { status: 404 });
}
if (!tiers.has(file.min_tier)) {
return NextResponse.json({ error: '구매 등급에서 접근할 수 없는 파일입니다' }, { status: 403 });
}
// 4) web-backend 호출 → DSM 공유 링크
// 3) web-backend 호출 → DSM 공유 링크
try {
const { url, expires_at } = await signLink({
file_path: file.file_path,

View File

@@ -4,7 +4,11 @@ import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/mypage';
const rawNext = searchParams.get('next') ?? '/mypage';
const next =
rawNext.startsWith('/') && !rawNext.startsWith('//') && !rawNext.startsWith('/\\')
? rawNext
: '/mypage';
// 리다이렉트 기준 URL 결정
// - dev: 항상 현재 request의 origin (localhost) → NEXT_PUBLIC_SITE_URL 무시

View File

@@ -0,0 +1,374 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
// 계좌이체 구매 모달.
// - 열릴 때 세션 확인 → 미로그인이면 로그인 유도(구매 폼 미노출)
// - 로그인 상태: 입금자명 + 약관 동의 → POST /api/orders
// - 주문 금액은 서버가 DB price로 확정한다. 아래 표시 금액은 안내용일 뿐이다.
// 접근성: role="dialog" aria-modal, Esc/backdrop 닫기, TopNav 드로어 패턴 차용.
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const BANK = { name: '케이뱅크', account: '100-116-337157', holder: '박재오' };
interface Props {
product: { id: string; name: string; price: number };
isOpen: boolean;
onClose: () => void;
}
type AuthState = 'checking' | 'guest' | 'user';
interface SuccessInfo {
orderId: string;
depositorName: string;
reused: boolean;
}
export default function BankTransferModal({ product, isOpen, onClose }: Props) {
const [authState, setAuthState] = useState<AuthState>('checking');
const [depositorName, setDepositorName] = useState('');
const [agreed, setAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState<SuccessInfo | null>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const priceLabel = `${product.price.toLocaleString('ko-KR')}`;
const loginHref = `/login?next=${encodeURIComponent(`/products/${product.id}`)}`;
// 열릴 때마다 상태 초기화 + 세션 확인
useEffect(() => {
if (!isOpen) return;
let mounted = true;
setAuthState('checking');
setDepositorName('');
setAgreed(false);
setSubmitting(false);
setError('');
setSuccess(null);
const supabase = createClient();
supabase.auth
.getSession()
.then(({ data }) => {
if (mounted) setAuthState(data.session?.user ? 'user' : 'guest');
})
.catch(() => {
if (mounted) setAuthState('guest');
});
return () => {
mounted = false;
};
}, [isOpen]);
// Esc 닫기 + body 스크롤 잠금
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [isOpen, onClose]);
// 초기 포커스: 모달 열릴 때 닫기 버튼으로 포커스 이동
useEffect(() => {
if (isOpen) closeBtnRef.current?.focus();
}, [isOpen]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const name = depositorName.trim();
if (!name || !agreed || submitting) return;
setSubmitting(true);
setError('');
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, depositorName: name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401) {
setSubmitting(false);
setAuthState('guest');
return;
}
setError(data?.error || '주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
setSubmitting(false);
return;
}
setSuccess({
orderId: data.orderId as string,
depositorName: name,
reused: Boolean(data.reused),
});
} catch {
setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
} finally {
setSubmitting(false);
}
},
[depositorName, agreed, submitting, product.id],
);
if (!isOpen) return null;
const canSubmit = depositorName.trim().length > 0 && agreed && !submitting;
return (
<div
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center p-0 sm:p-4"
style={{ background: 'rgba(15,23,42,0.45)' }}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label={success ? '주문 접수 완료' : `${product.name} 구매`}
onClick={(e) => e.stopPropagation()}
className="w-full sm:max-w-md max-h-[92vh] overflow-y-auto rounded-t-2xl sm:rounded-2xl shadow-xl"
style={{ background: 'var(--jsm-surface)' }}
>
{/* 헤더 */}
<div
className="sticky top-0 flex items-center justify-between px-6 h-16 border-b"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2
className="text-base font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{success ? '주문 접수 완료' : '계좌이체 구매'}
</h2>
<button
ref={closeBtnRef}
onClick={onClose}
aria-label="닫기"
className="p-2 -mr-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-6">
{/* 상품 요약 */}
{!success && (
<div
className="rounded-lg border px-4 py-3.5 mb-6 flex items-center justify-between gap-3"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-sm font-semibold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.name}
</span>
<span
className="text-base font-bold shrink-0"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{priceLabel}
</span>
</div>
)}
{/* ── 세션 확인 중 ── */}
{authState === 'checking' && !success && (
<div className="flex items-center justify-center py-8">
<div
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
/>
</div>
)}
{/* ── 미로그인 ── */}
{authState === 'guest' && !success && (
<div className="text-center py-2">
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
<Link
href={loginHref}
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</Link>
</div>
)}
{/* ── 로그인 상태: 구매 폼 ── */}
{authState === 'user' && !success && (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="depositor-name"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>*</span>
</label>
<input
id="depositor-name"
type="text"
value={depositorName}
onChange={(e) => setDepositorName(e.target.value)}
placeholder="입금하실 분의 성함"
required
maxLength={40}
disabled={submitting}
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
}}
/>
<p className="mt-1.5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
.
</p>
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
disabled={submitting}
className="mt-0.5 w-4 h-4 shrink-0 accent-[var(--jsm-accent)]"
/>
<span className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<Link
href="/legal/terms"
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
{' '}
<Link
href="/legal/refund"
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
.
</span>
</label>
{error && (
<div
className="px-3.5 py-3 rounded-lg text-sm break-keep"
style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', ...KOR_BODY }}
>
{error}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{
background: canSubmit ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
color: '#ffffff',
cursor: canSubmit ? 'pointer' : 'not-allowed',
...KOR_BODY,
}}
>
{submitting ? '처리 중...' : '주문하기'}
</button>
</form>
)}
{/* ── 성공 화면 ── */}
{success && (
<div>
<p
className="text-lg font-bold mb-2 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{success.reused ? '이미 접수된 주문이 있습니다' : '주문이 접수되었습니다'}
</p>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
<dl
className="rounded-lg border divide-y mb-5"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}
>
{[
{ k: '입금 계좌', v: `${BANK.name} ${BANK.account}` },
{ k: '예금주', v: BANK.holder },
{ k: '입금 금액', v: priceLabel },
{ k: '입금자명', v: success.depositorName },
].map((row) => (
<div
key={row.k}
className="flex items-center justify-between gap-3 px-4 py-3"
style={{ borderColor: 'var(--jsm-line)' }}
>
<dt className="text-xs shrink-0" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{row.k}
</dt>
<dd
className="text-sm font-semibold text-right break-all"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
{row.v}
</dd>
</div>
))}
</dl>
<p
className="text-xs leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
. 24 .
</p>
<Link
href="/mypage?tab=products"
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</Link>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -55,11 +55,14 @@ function ContactFormInner() {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const fieldClass =
'w-full px-3.5 py-2.5 text-sm border rounded-xl outline-none bg-white disabled:bg-slate-50 transition-colors focus:ring-2 focus:ring-[var(--jsm-accent)] focus:border-[var(--jsm-accent)]';
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
<span className="text-red-500">*</span>
</label>
<input
@@ -70,11 +73,12 @@ function ContactFormInner() {
required
disabled={status === 'loading'}
placeholder="홍길동"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
className={fieldClass}
style={{ borderColor: 'var(--jsm-line)' }}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}></label>
<input
type="tel"
name="phone"
@@ -82,13 +86,14 @@ function ContactFormInner() {
onChange={handleChange}
disabled={status === 'loading'}
placeholder="010-0000-0000"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
className={fieldClass}
style={{ borderColor: 'var(--jsm-line)' }}
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
<span className="text-red-500">*</span>
</label>
<input
@@ -99,18 +104,20 @@ function ContactFormInner() {
required
disabled={status === 'loading'}
placeholder="example@email.com"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
className={fieldClass}
style={{ borderColor: 'var(--jsm-line)' }}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"> </label>
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}> </label>
<select
name="service"
value={formData.service}
onChange={handleChange}
disabled={status === 'loading'}
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
className={fieldClass}
style={{ borderColor: 'var(--jsm-line)' }}
>
<option> </option>
<option>AI - </option>
@@ -125,7 +132,7 @@ function ContactFormInner() {
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
<span className="text-red-500">*</span>
</label>
<textarea
@@ -136,31 +143,33 @@ function ContactFormInner() {
rows={5}
disabled={status === 'loading'}
placeholder="문의하실 내용을 자유롭게 작성해주세요. 프로젝트 목적, 원하시는 기능, 예산 등을 적어주시면 더 정확한 답변이 가능합니다."
className="w-full px-3.5 py-2.5 text-sm border border-slate-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none bg-white disabled:bg-slate-50"
className={`${fieldClass} resize-none`}
style={{ borderColor: 'var(--jsm-line)' }}
/>
</div>
{status === 'success' && (
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm px-4 py-3 rounded-xl">
! 24 .
. 2 .
</div>
)}
{status === 'error' && (
<div className="bg-red-50 border border-red-200 text-red-800 text-sm px-4 py-3 rounded-xl">
{errorMessage}
{errorMessage}
</div>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white py-3 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full text-white py-3 rounded-xl text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)' }}
>
{status === 'loading' ? '전송 중...' : '문의 보내기'}
</button>
<p className="text-slate-400 text-xs text-center">
24 ·
<p className="text-xs text-center" style={{ color: 'var(--jsm-ink-faint)' }}>
2 ·
</p>
</form>
);

View File

@@ -7,111 +7,147 @@ export default function PublicShell({ children }: { children: React.ReactNode })
<>
<TopNav />
<main
className="min-h-screen pt-20"
className="min-h-screen pt-16"
style={{
background: 'var(--kx-surface)',
color: 'var(--kx-on-surface)',
background: 'var(--jsm-bg)',
color: 'var(--jsm-ink)',
}}
>
{children}
<footer className="bg-black text-white/70 px-6 lg:px-12 py-14 text-sm border-t border-white/10">
<footer
className="text-white/70 px-6 lg:px-12 py-14 text-sm"
style={{ background: 'var(--jsm-navy)' }}
>
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-12 md:gap-8">
{/* 좌 — JSM + social */}
{/* 좌 — JSM + 연락처 */}
<div>
<p
className="kx-display font-bold text-2xl mb-5 text-white tracking-tight"
style={{ letterSpacing: '0.02em' }}
>
JSM
</p>
<div className="flex items-center gap-3">
<a
href="https://www.youtube.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="YouTube"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
<div className="flex items-baseline gap-2 mb-4">
<span
className="font-black text-2xl text-white"
style={{ letterSpacing: '-0.02em' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1c.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z" />
</svg>
</a>
<a
href="https://x.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="X (Twitter)"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<a
href="https://www.instagram.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
</svg>
</a>
<a
href="mailto:bgg8988@gmail.com"
aria-label="Email"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</a>
JSM
</span>
<span className="text-sm text-white/50" style={{ letterSpacing: '-0.01em' }}>
</span>
</div>
<a
href="mailto:bgg8988@gmail.com"
className="flex items-center gap-2 text-white/50 hover:text-white transition-colors duration-150 text-sm"
style={{ letterSpacing: '-0.01em' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
bgg8988@gmail.com
</a>
</div>
{/* 우 — Link groups */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-10">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-10">
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">SaaS </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
</p>
<ul className="space-y-2.5">
<li><Link href="/packages" className="hover:text-white transition"> </Link></li>
<li><Link href="/packages" className="hover:text-white transition"> </Link></li>
<li>
<Link
href="/outsourcing"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/products"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">AI </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
</p>
<ul className="space-y-2.5">
<li><Link href="/music/packs" className="hover:text-white transition"> </Link></li>
<li><Link href="/music/samples" className="hover:text-white transition"> </Link></li>
<li><Link href="/music/packs#pricing" className="hover:text-white transition"></Link></li>
<li>
<a
href="mailto:bgg8988@gmail.com"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</a>
</li>
<li>
<Link
href="/outsourcing#process"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4"> </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
Legal
</p>
<ul className="space-y-2.5">
<li><Link href="/work/freelance" className="hover:text-white transition"> </Link></li>
<li><Link href="/work/website" className="hover:text-white transition"> </Link></li>
<li><Link href="/work/saju" className="hover:text-white transition">AI </Link></li>
<li><a href="mailto:bgg8988@gmail.com" className="hover:text-white transition"></a></li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">Legal</p>
<ul className="space-y-2.5">
<li><Link href="/legal/terms" className="hover:text-white transition"></Link></li>
<li><Link href="/legal/privacy" className="hover:text-white transition"></Link></li>
<li><Link href="/legal/refund" className="hover:text-white transition"> </Link></li>
<li>
<Link
href="/legal/terms"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/legal/privacy"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/legal/refund"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
</div>
</div>
<div className="mt-12 pt-6 border-t border-white/10 flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed">
<div
className="mt-12 pt-6 border-t flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed"
style={{ borderColor: 'rgba(255,255,255,0.08)' }}
>
<span>대표자: 박재오</span>
<span>사업자등록번호: 267-53-00822</span>
<span> 22 22, 1 109</span>

View File

@@ -7,9 +7,8 @@ import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
const LINKS = [
{ href: '/packages', label: 'SaaS 제품' },
{ href: '/music', label: 'AI 음악' },
{ href: '/work', label: '커스텀 외주' },
{ href: '/outsourcing', label: '외주 개발' },
{ href: '/products', label: '소프트웨어' },
];
export default function TopNav() {
@@ -59,6 +58,13 @@ export default function TopNav() {
}
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
const isActive = (href: string) => {
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(href + '/');
@@ -67,187 +73,222 @@ export default function TopNav() {
return (
<>
<header
className={[
'fixed left-1/2 -translate-x-1/2 z-50 w-full border-b border-transparent',
'md:rounded-full md:border transition-all duration-300 ease-out',
scrolled
? 'top-4 max-w-3xl md:shadow-[0_10px_40px_rgba(0,0,0,0.35)] md:border-white/10'
: 'top-0 max-w-none',
].join(' ')}
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
style={{
background: scrolled ? 'rgba(10,10,12,0.6)' : 'transparent',
backdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
WebkitBackdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
background: scrolled ? 'var(--jsm-surface)' : 'transparent',
borderBottom: scrolled ? '1px solid var(--jsm-line)' : '1px solid transparent',
boxShadow: scrolled ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
}}
>
<nav
className={[
'flex w-full items-center justify-between transition-all duration-300 ease-out',
scrolled ? 'h-14 px-4 md:px-3' : 'h-20 px-6 lg:px-12',
].join(' ')}
>
<Link
href="/"
className="kx-display text-2xl font-black tracking-tight kx-gradient-text"
style={{ textDecoration: 'none', letterSpacing: '0.02em' }}
>
JSM
</Link>
<div className="hidden md:flex items-center gap-8">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="text-sm font-medium transition-colors"
style={{
color: isActive(l.href) ? '#fff' : 'var(--kx-on-variant)',
borderBottom: isActive(l.href) ? '2px solid var(--kx-primary)' : '2px solid transparent',
paddingBottom: 4,
textDecoration: 'none',
}}
>
{l.label}
</Link>
))}
</div>
<div className="flex items-center gap-3">
{user ? (
<>
<Link
href="/mypage"
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
<button
onClick={handleLogout}
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium transition-colors"
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
>
</button>
</>
) : (
<>
<Link
href="/login"
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</>
)}
<button
onClick={() => setOpen(true)}
aria-label="메뉴 열기"
className="md:hidden p-2 rounded-lg"
style={{ color: 'var(--kx-on-surface)' }}
<nav className="max-w-7xl mx-auto flex w-full items-center justify-between h-16 px-6 lg:px-8">
{/* 로고 */}
<Link
href="/"
className="flex items-baseline gap-2"
style={{ textDecoration: 'none' }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</nav>
</header>
{/* 모바일 오버레이 */}
{open && (
<div
className="fixed inset-0 z-[60] md:hidden flex flex-col"
style={{ background: 'rgba(6,14,32,0.98)', backdropFilter: 'blur(16px)' }}
>
<div className="flex items-center justify-between px-6 h-20">
<span className="kx-display text-2xl font-black kx-gradient-text" style={{ letterSpacing: '0.02em' }}>JSM</span>
<button
onClick={() => setOpen(false)}
aria-label="메뉴 닫기"
className="p-2"
style={{ color: 'var(--kx-on-surface)' }}
<span
className="text-xl font-black tracking-tight"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 flex flex-col gap-2 px-6 pt-6">
JSM
</span>
<span
className="hidden sm:inline text-sm font-medium"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
>
</span>
</Link>
{/* 데스크탑 링크 */}
<div className="hidden md:flex items-center gap-1">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="kx-display text-2xl font-bold py-3"
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
style={{
color: isActive(l.href) ? 'var(--kx-primary)' : 'var(--kx-on-surface)',
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink-soft)',
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
{l.label}
</Link>
))}
<div className="mt-6 flex flex-col gap-2">
</div>
{/* 데스크탑 CTA + auth */}
<div className="flex items-center gap-2">
{user ? (
<>
<Link
href="/mypage"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
</>
) : (
<Link
href="/login"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
)}
<Link
href="/outsourcing#contact"
className="hidden sm:inline-flex items-center px-4 py-2 rounded-lg text-sm font-semibold transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
</Link>
{/* 모바일 햄버거 */}
<button
onClick={() => setOpen(true)}
aria-label="메뉴 열기"
aria-expanded={open}
className="md:hidden p-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink)' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</nav>
</header>
{/* 모바일 드로어 */}
{open && (
<div
className="fixed inset-0 z-[60] md:hidden"
style={{ background: 'rgba(15,23,42,0.4)' }}
onClick={() => setOpen(false)}
>
<div
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
style={{ background: 'var(--jsm-surface)' }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="메뉴"
>
{/* 드로어 헤더 */}
<div
className="flex items-center justify-between px-6 h-16 border-b"
style={{ borderColor: 'var(--jsm-line)' }}
>
<div className="flex items-baseline gap-2">
<span
className="text-lg font-black tracking-tight"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
JSM
</span>
<span
className="text-xs font-medium"
style={{ color: 'var(--jsm-ink-soft)' }}
>
</span>
</div>
<button
onClick={() => setOpen(false)}
aria-label="메뉴 닫기"
className="p-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 드로어 링크 */}
<div className="flex-1 flex flex-col px-4 pt-4 gap-1">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="text-base font-semibold px-3 py-3 rounded-lg transition-colors duration-150"
style={{
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
{l.label}
</Link>
))}
<div
className="my-4 border-t"
style={{ borderColor: 'var(--jsm-line)' }}
/>
{user ? (
<>
<div className="flex gap-3">
<Link
href="/mypage"
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</div>
<Link
href="/mypage"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="w-full py-3 text-center text-sm font-medium transition-colors"
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
className="text-left text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
</>
) : (
<div className="flex gap-3">
<Link
href="/login"
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</div>
<Link
href="/login"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
)}
</div>
{/* 드로어 하단 CTA */}
<div className="px-4 pb-6">
<Link
href="/outsourcing#contact"
className="flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors duration-150"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
</Link>
</div>
</div>
</div>
)}

View File

@@ -36,19 +36,32 @@
--card-bg: #ffffff;
--border: #dbe8ff;
/* ─── Kinetic Ether Tokens (다크 테마 섹션 전용) ─── */
--kx-surface: #060e20;
--kx-surface-low: #091328;
--kx-surface-mid: #0f1930;
--kx-surface-high: #141f38;
--kx-surface-bright: #1f2b49;
--kx-on-surface: #dee5ff;
--kx-on-variant: #a3aac4;
--kx-primary: #cc97ff;
--kx-primary-dim: #9c48ea;
--kx-secondary: #53ddfc;
--kx-secondary-dim: #40ceed;
--kx-outline: rgba(64, 72, 93, 0.15);
/* === JSM Professional tokens (2026-06 renewal) === */
--jsm-bg: #f8fafc; /* slate-50 본문 배경 */
--jsm-surface: #ffffff; /* 카드 */
--jsm-surface-alt: #f1f5f9; /* slate-100 섹션 교차 배경 */
--jsm-ink: #0f172a; /* slate-900 본문 텍스트 */
--jsm-ink-soft: #475569; /* slate-600 보조 텍스트 */
--jsm-ink-faint: #94a3b8; /* slate-400 캡션 */
--jsm-line: #e2e8f0; /* slate-200 보더 */
--jsm-navy: #0b1f3a; /* 딥네이비 — 푸터/다크 섹션 */
--jsm-accent: #1d4ed8; /* blue-700 포인트 (단일 포인트 컬러) */
--jsm-accent-hover: #1e40af; /* blue-800 */
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
/* 기존 kx 변수 재매핑 (잔여 참조 호환용) */
--kx-surface: var(--jsm-bg);
--kx-surface-low: var(--jsm-surface-alt);
--kx-surface-mid: var(--jsm-surface);
--kx-surface-high: var(--jsm-surface);
--kx-surface-bright: var(--jsm-surface-alt);
--kx-on-surface: var(--jsm-ink);
--kx-on-variant: var(--jsm-ink-soft);
--kx-primary: var(--jsm-accent);
--kx-primary-dim: var(--jsm-accent-hover);
--kx-secondary: var(--jsm-accent);
--kx-secondary-dim: var(--jsm-accent-hover);
--kx-outline: var(--jsm-line);
}
@theme inline {
@@ -56,8 +69,8 @@
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--font-sans: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
--font-mono: var(--font-jua), 'Jua', ui-monospace, monospace;
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
}
* {
@@ -69,16 +82,16 @@ html {
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
background: var(--jsm-bg);
color: var(--jsm-ink);
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* font-mono utility → Jua 통일 */
/* font-mono utility → Pretendard 통일 */
.font-mono, code, pre, kbd, samp {
font-family: var(--font-jua), 'Jua', ui-monospace, monospace;
font-family: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
}
/* Dashboard layout */
@@ -86,7 +99,7 @@ body {
display: flex;
height: 100dvh;
overflow: hidden;
background: var(--background);
background: var(--jsm-bg);
}
.main-content {
@@ -107,18 +120,19 @@ body {
.kx-section {
background: var(--kx-surface);
color: var(--kx-on-surface);
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
}
.kx-section p, .kx-section li, .kx-section span:not(.kx-label) {
color: var(--kx-on-variant);
}
.kx-display {
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
letter-spacing: -0.01em;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
color: inherit;
}
.kx-label {
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -147,15 +161,14 @@ body {
0 0 80px 0 rgba(83, 221, 252, 0.08);
}
.kx-btn-primary {
background: linear-gradient(135deg, #cc97ff 0%, #c284ff 100%);
color: #0b0113;
background: var(--jsm-accent);
color: #ffffff;
font-weight: 700;
box-shadow: 0 0 20px 0 rgba(168, 85, 247, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease;
transition: background 0.15s ease, transform 0.15s ease;
}
.kx-btn-primary:hover {
background: var(--jsm-accent-hover);
transform: translateY(-1px);
box-shadow: 0 0 28px 0 rgba(168, 85, 247, 0.55);
}
.kx-btn-ghost {
color: var(--kx-secondary);
@@ -166,10 +179,8 @@ body {
background: var(--kx-surface-bright);
}
.kx-gradient-text {
background: linear-gradient(135deg, #cc97ff 0%, #53ddfc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--jsm-ink);
-webkit-text-fill-color: var(--jsm-ink);
}
.kx-orb {
position: absolute;

View File

@@ -1,4 +1,6 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'CONTOUR — 나를 더 선명하게 이해하는 3분',
@@ -22,7 +24,8 @@ export const metadata: Metadata = {
},
};
export default function GyeolLayout({ children }: { children: React.ReactNode }) {
export default async function GyeolLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('gyeol'))) notFound();
return (
<div
className="min-h-screen"

View File

@@ -1,33 +1,23 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Jua } from "next/font/google";
import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
import "./globals.css";
import DashboardShell from "./components/DashboardShell";
import { GlassFilter } from "./components/LiquidGlass";
const jua = Jua({
weight: "400",
subsets: ["latin"],
variable: "--font-jua",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
default: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
template: "%s | 쟁승메이드",
},
description:
"Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악과 뮤비를 1시간 만에 완성하는 4단계 크리에이터 툴킷. ₩39,000부터.",
"7년차 대기업 백엔드 개발자가 직접 설계하고 만듭니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
keywords: [
"AI 음악",
"AI 작곡",
"Suno 프롬프트",
"AI 뮤직비디오",
"유튜브 쇼츠 음악",
"AI 뮤비",
"음악 프롬프트",
"AI 사주",
"외주 개발",
"소프트웨어 개발",
"웹사이트 제작",
"업무 자동화",
"백엔드 개발자",
"프리랜서 개발자",
],
authors: [{ name: "박재오", url: "https://jaengseung-made.com" }],
creator: "박재오",
@@ -36,22 +26,23 @@ export const metadata: Metadata = {
locale: "ko_KR",
url: "https://jaengseung-made.com",
siteName: "쟁승메이드",
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악·뮤비를 1시간에 완성하는 4단계 크리에이터 툴킷.",
"7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
images: [
{
url: "https://jaengseung-made.com/og-image.png",
width: 1200,
height: 630,
alt: "쟁승메이드 — AI 프롬프트 · 자동화 스토어",
alt: "쟁승메이드 — 외주 개발 · 완성 소프트웨어",
},
],
},
twitter: {
card: "summary_large_image",
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
description: "AI로 음악과 뮤비를 1시간 만에. Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿.",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"7년차 대기업 백엔드 개발자가 직접 만듭니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공합니다.",
},
robots: {
index: true,
@@ -68,39 +59,35 @@ const jsonLd = {
'@id': 'https://jaengseung-made.com/#person',
name: '박재오',
url: 'https://jaengseung-made.com',
jobTitle: '백엔드 개발자 · AI 자동화 전문가',
jobTitle: '백엔드 개발자 · 외주 개발 전문가',
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', 'AI 프롬프트', 'AI 자동화', '업무 자동화', 'ChatGPT', 'Claude'],
description: '현직 엔지니어. AI 음악 생성 개발 가이드 패키지, AI 사주 분석 등 AI 크리에이티브 도구를 직접 개발·운영합니다.',
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
description: '7년차 대기업 백엔드 개발자. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 직접 설계·개발·운영합니다.',
},
{
'@type': 'LocalBusiness',
'@id': 'https://jaengseung-made.com/#business',
name: '쟁승메이드',
url: 'https://jaengseung-made.com',
description: 'AI 음악 생성 개발 가이드 패키지, AI 사주 분석. 현직 엔지니어가 직접 설계·운영하는 AI 크리에이티브 스토어.',
description: '7년차 대기업 백엔드 개발자가 직접 설계·개발·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
priceRange: '₩',
areaServed: '대한민국',
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: '쟁승메이드 AI 도구 · 서비스',
name: '쟁승메이드 개발 서비스',
itemListElement: [
{ '@type': 'Offer', price: '39000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (입문)', url: 'https://jaengseung-made.com/music/packs', description: 'Suno 프롬프트 조합법 + MV 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. AI 음악 생성 개발 가이드 (1회 결제).' } },
{ '@type': 'Offer', price: '99000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (프로)', url: 'https://jaengseung-made.com/music/packs', description: '입문 전체 + 샘플 프로젝트 1개(.prj · 영상 포함). 1회 결제.' } },
{ '@type': 'Offer', price: '149000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (마스터)', url: 'https://jaengseung-made.com/music/packs', description: '프로 전체 + 샘플 다수 + 우선 업데이트·베타 선공개. 1회 결제.' } },
{ '@type': 'Offer', price: '0', priceCurrency: 'KRW', url: 'https://jaengseung-made.com/work/saju', itemOffered: { '@type': 'Service', name: 'AI 사주 분석', url: 'https://jaengseung-made.com/work/saju', description: '생년월일 기반 AI 사주팔자 분석. 무료 체험 가능.' } },
{
'@type': 'Offer',
url: 'https://jaengseung-made.com/work/freelance',
url: 'https://jaengseung-made.com/outsourcing',
availability: 'https://schema.org/InStock',
itemOffered: {
'@type': 'Service',
name: '외주 개발',
url: 'https://jaengseung-made.com/work/freelance',
url: 'https://jaengseung-made.com/outsourcing',
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
serviceType: 'Custom Software Development',
provider: { '@id': 'https://jaengseung-made.com/#business' },
@@ -109,12 +96,12 @@ const jsonLd = {
},
{
'@type': 'Offer',
url: 'https://jaengseung-made.com/work/website',
url: 'https://jaengseung-made.com/outsourcing',
availability: 'https://schema.org/InStock',
itemOffered: {
'@type': 'Service',
name: '웹사이트 제작',
url: 'https://jaengseung-made.com/work/website',
url: 'https://jaengseung-made.com/outsourcing',
description: 'Next.js 기반 기업·브랜드 웹사이트 맞춤 제작. 반응형 + SEO + 배포 포함.',
serviceType: 'Web Development',
provider: { '@id': 'https://jaengseung-made.com/#business' },
@@ -133,7 +120,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="ko" data-scroll-behavior="smooth" className={jua.variable}>
<html lang="ko" data-scroll-behavior="smooth">
<head>
<script
type="application/ld+json"
@@ -156,7 +143,6 @@ export default function RootLayout({
</Script>
</head>
<body className="antialiased">
<GlassFilter />
<DashboardShell>{children}</DashboardShell>
</body>
</html>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function RefundPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"> </h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}> </h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<section>

View File

@@ -6,6 +6,13 @@ import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import { Suspense } from 'react';
// next 파라미터가 안전한 내부 경로(`/`로 시작, `//`·`/\` 프로토콜-상대 아님)일 때만 허용.
function safeNext(raw: string | null): string {
if (!raw) return '/mypage';
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '/mypage';
return raw;
}
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -15,6 +22,7 @@ function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const supabase = createClient();
const next = safeNext(searchParams.get('next'));
useEffect(() => {
if (searchParams.get('error')) {
@@ -22,7 +30,7 @@ function LoginForm() {
}
// 이미 로그인된 경우 리다이렉트
supabase.auth.getUser().then(({ data }) => {
if (data.user) router.push('/mypage');
if (data.user) router.push(next);
});
}, []);
@@ -52,7 +60,7 @@ function LoginForm() {
if (error) {
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
} else {
router.push('/mypage');
router.push(next);
router.refresh();
}
}
@@ -66,124 +74,86 @@ function LoginForm() {
process.env.NODE_ENV === 'development'
? window.location.origin
: (process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin);
// next는 /auth/callback에서 read해 로그인 후 목적지로 리다이렉트 (기본 /mypage)
const callbackUrl = `${base}/auth/callback?next=${encodeURIComponent(next)}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${base}/auth/callback` },
options: { redirectTo: callbackUrl },
});
if (error) setMessage('Google 로그인 오류: ' + error.message);
};
return (
<div className="min-h-screen bg-[#04102b] flex items-center justify-center p-4">
{/* 배경 장식 */}
<div
className="absolute inset-0 pointer-events-none"
style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}
/>
const isSuccess =
message.includes('완료') || message.includes('확인해주세요');
<div className="relative w-full max-w-md">
{/* 로고 */}
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-12"
style={{ background: 'var(--jsm-bg)' }}
>
<div className="w-full max-w-sm">
{/* 워드마크 */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className="w-12 h-12 rounded-xl bg-[#1a56db] flex items-center justify-center text-white font-bold text-xl">
</div>
<div className="text-left">
<div className="text-white font-bold text-xl leading-tight"></div>
<div className="text-blue-400 text-xs font-medium"> </div>
</div>
<Link
href="/"
className="inline-block"
style={{
fontWeight: 800,
fontSize: '1.375rem',
letterSpacing: '-0.03em',
color: 'var(--jsm-ink)',
transition: 'color 0.15s',
}}
>
</Link>
<p
className="mt-2 text-sm break-keep"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
>
{isSignUp
? '가입 후 의뢰 현황과 구매 내역을 관리하세요'
: '로그인하고 의뢰 현황과 구매 내역을 확인하세요'}
</p>
</div>
{/* 카드 */}
<div className="bg-white/5 border border-white/10 backdrop-blur rounded-2xl p-8 shadow-2xl">
<div className="text-center mb-7">
<h1 className="text-2xl font-extrabold text-white mb-1">
{isSignUp ? '회원가입' : '로그인'}
</h1>
<p className="text-blue-300/60 text-sm">
{isSignUp
? '가입 후 사주 기록, 결제 내역을 관리하세요'
: '사주 기록·결제·의뢰 내역을 확인하세요'}
</p>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm font-medium ${
message.includes('완료') || message.includes('확인해주세요')
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-300'
: 'bg-red-500/10 border border-red-500/30 text-red-300'
}`}>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4 mb-5">
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="password"
placeholder="6자 이상"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white font-bold py-3 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
</button>
</form>
{/* 전환 링크 */}
<div className="text-center mb-5">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm text-blue-400 hover:text-blue-300 transition"
>
{isSignUp ? '이미 계정이 있으신가요? 로그인 →' : '아직 계정이 없으신가요? 회원가입 →'}
</button>
</div>
{/* 구분선 */}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-transparent text-slate-500 text-xs"> </span>
</div>
</div>
{/* 구글 로그인 */}
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition text-white font-medium text-sm"
<div
className="rounded-xl p-8"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
boxShadow: '0 1px 4px 0 rgba(15,23,42,0.06), 0 4px 16px 0 rgba(15,23,42,0.04)',
}}
>
{/* 카드 헤더 */}
<h1
className="text-xl font-bold mb-6 text-center"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
{isSignUp ? '회원가입' : '로그인'}
</h1>
{/* Google 로그인 */}
<button
type="button"
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-lg text-sm font-medium mb-5"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface-alt)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface)';
}}
>
{/* Google G 로고 */}
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
@@ -191,12 +161,169 @@ function LoginForm() {
</svg>
Google로
</button>
{/* 구분선 */}
<div className="relative mb-5">
<div
className="absolute inset-0 flex items-center"
aria-hidden
>
<div className="w-full" style={{ borderTop: '1px solid var(--jsm-line)' }} />
</div>
<div className="relative flex justify-center">
<span
className="px-3 text-xs"
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-faint)' }}
>
</span>
</div>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div
className="mb-4 px-3.5 py-3 rounded-lg text-sm"
style={{
background: isSuccess ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${isSuccess ? '#bbf7d0' : '#fecaca'}`,
color: isSuccess ? '#15803d' : '#dc2626',
}}
>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4">
<div>
<label
htmlFor="login-email"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
>
</label>
<input
id="login-email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'border-color 0.15s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-line)';
}}
/>
</div>
<div>
<label
htmlFor="login-password"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
>
</label>
<input
id="login-password"
type="password"
placeholder="6자 이상 입력해주세요"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'border-color 0.15s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-line)';
}}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-lg text-sm font-semibold mt-1"
style={{
background: loading ? 'var(--jsm-ink-faint)' : 'var(--jsm-accent)',
color: loading ? '#ffffff' : '#ffffff',
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background 0.15s, transform 0.15s',
letterSpacing: '-0.01em',
}}
onMouseEnter={(e) => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent-hover)';
}}
onMouseLeave={(e) => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent)';
}}
>
{loading ? '처리 중...' : isSignUp ? '가입하기' : '로그인'}
</button>
</form>
{/* 가입/로그인 전환 */}
<div className="mt-5 text-center">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm"
style={{
color: 'var(--jsm-accent)',
background: 'none',
border: 'none',
cursor: 'pointer',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent-hover)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent)';
}}
>
{isSignUp
? '이미 계정이 있으신가요? 로그인'
: '계정이 없으신가요? 회원가입'}
</button>
</div>
</div>
{/* 홈으로 */}
<div className="text-center mt-6">
<Link href="/" className="text-slate-500 hover:text-slate-300 text-sm transition">
<Link
href="/"
className="text-sm"
style={{ color: 'var(--jsm-ink-faint)', transition: 'color 0.15s', letterSpacing: '-0.01em' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-soft)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-faint)';
}}
>
</Link>
</div>
</div>
@@ -207,8 +334,14 @@ function LoginForm() {
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#04102b] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<div
className="min-h-screen flex items-center justify-center"
style={{ background: 'var(--jsm-bg)' }}
>
<div
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
/>
</div>
}>
<LoginForm />

View File

@@ -1,10 +1,13 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'AI 음악 제품',
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.',
};
export default function MusicLayout({ children }: { children: React.ReactNode }) {
export default async function MusicLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('music'))) notFound();
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

539
app/outsourcing/page.tsx Normal file
View File

@@ -0,0 +1,539 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import ContactForm from '@/app/components/ContactForm';
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
// 메인(/)의 토큰·타이포 패턴(KOR_TIGHT/KOR_BODY)·섹션 리듬과 일관되게 구성한다.
export const metadata: Metadata = {
title: '외주 개발',
description:
'7년차 대기업 백엔드 개발자가 직접 진행하는 맞춤 소프트웨어 외주 개발. 웹 서비스, 업무 자동화, API·백엔드, 봇, AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
};
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const FIELDS = [
{
t: '웹 서비스 개발',
d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.',
},
{
t: '웹사이트 제작',
d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.',
},
{
t: '업무 자동화',
d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.',
},
{
t: 'API·백엔드',
d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.',
},
{
t: '텔레그램·디스코드 봇',
d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.',
},
{
t: 'AI 연동 개발',
d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.',
},
];
const PROCESS = [
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다. 기획이 안 잡혔어도 괜찮습니다.' },
{ n: '02', t: '견적·범위 확정', d: '기능 범위와 일정을 정리해 영업일 2일 내 견적으로 회신드립니다.' },
{ n: '03', t: '계약·착수', d: '계약서 체결 후 착수금 30%를 받고 개발을 시작합니다.' },
{ n: '04', t: '개발·중간 공유', d: '주 1회 이상 진행 상황을 공유하며 방향을 맞춰 갑니다.' },
{ n: '05', t: '납품·검수', d: '완성본을 인도하고 함께 검수합니다. 전체 소스와 배포 문서를 전달합니다.' },
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
];
// 기존 work/freelance(lib/freelance-portfolio) 실사례를 새 토큰 기준으로 재구성.
const CASES = [
{
t: '주식 자동매매 시스템',
cat: '실시간 트레이딩 · 직접 운영 중',
live: true,
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
tags: ['Python', 'Telegram Bot', '실시간 주문'],
},
{
t: '부동산 청약 자동 수집·매칭',
cat: '크롤링 · 직접 운영 중',
live: true,
d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 푸시합니다.',
tags: ['Python', '크롤링', '조건 매칭'],
},
{
t: 'AI 콘텐츠 자동화 파이프라인',
cat: 'AI 연동 · 직접 운영 중',
live: true,
d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.',
tags: ['AI 연동', '검수 워크플로우', '자동 발행'],
},
{
t: 'Gmail 자동화 RPA',
cat: 'RPA · 납품 완료',
live: false,
d: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림을 전송합니다.',
tags: ['Python', 'Gmail API'],
},
{
t: '쇼핑몰 가격 모니터링 봇',
cat: '웹 스크래핑 · 납품 완료',
live: false,
d: '경쟁사 상품 가격을 매일 모니터링해 변동 시 텔레그램으로 즉시 알립니다.',
tags: ['Python', 'Selenium', 'Telegram Bot'],
},
{
t: '영업 일보 자동화 시스템',
cat: '엑셀 자동화 · 납품 완료',
live: false,
d: '엑셀 데이터를 자동 집계해 일·주·월별 보고서 PDF를 생성하고 매일 09시 발송합니다.',
tags: ['Python', 'OpenPyXL', 'ReportLab'],
},
];
// /work/website/samples/* 중 대표 샘플 — 이 라우트는 숨김이 아니라 포트폴리오용으로 잔존.
const SAMPLES = [
{ slug: 'corporate', t: '기업 홈페이지', sub: '테크솔루션㈜', tag: 'B2B · 신뢰' },
{ slug: 'shopping', t: '개인 쇼핑몰', sub: 'MELLOW STUDIO', tag: '쇼핑몰 · 브랜드' },
{ slug: 'dashboard', t: '관리자 대시보드', sub: 'DataFlow SaaS', tag: 'SaaS · 자동화' },
{ slug: 'portfolio', t: '개인 포트폴리오', sub: 'Kim Jisu', tag: '크리에이터 · 수주' },
];
const FAQ = [
{
q: '견적은 어떻게 산정되나요?',
a: '기능 범위와 구현 난이도를 기준으로 산정합니다. 상담에서 필요한 기능을 함께 정리한 뒤, 영업일 2일 내에 범위·일정·금액을 명시한 견적으로 회신드립니다. 추측으로 부풀리지 않고 실제 작업량 기준으로 잡습니다.',
},
{
q: '수정 요청은 몇 번까지 가능한가요?',
a: '합의한 범위 안에서는 2회까지 무상으로 수정해 드립니다. 범위를 벗어나는 기능 추가나 방향 전환은 별도로 협의해 진행합니다. 무엇이 범위 안/밖인지는 착수 전 견적에 미리 명시합니다.',
},
{
q: '소스코드도 제공되나요?',
a: '제공됩니다. 잔금 완납 시 전체 소스코드와 배포·실행 문서를 함께 전달합니다. 직접 운영하시거나 다른 개발자에게 이어 맡기셔도 문제없도록 인도합니다.',
},
{
q: '납품 후 유지보수는요?',
a: '납품일로부터 30일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.',
},
];
function ArrowRight() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
export default function OutsourcingPage() {
return (
<>
{/* ─── 1. Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
<div className="max-w-3xl">
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)', ...KOR_BODY }}
>
</span>
<h1
className="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{' '}
<span style={{ color: 'var(--jsm-accent)' }}> </span>
</h1>
<p
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. . 7
· .
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<Link
href="#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
<Link
href="#portfolio"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
>
</Link>
</div>
</div>
</div>
</section>
{/* ─── 2. 제공 분야 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Scope
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
</div>
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{FIELDS.map((f) => (
<div
key={f.t}
className="rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{f.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{f.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── 3. 진행 프로세스 ─── */}
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Process
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
</h2>
</div>
<div
className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-px rounded-2xl overflow-hidden border"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
>
{PROCESS.map((s) => (
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
<span
className="text-sm font-bold"
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
>
{s.n}
</span>
<h3
className="mt-4 text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{s.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── 4. 포트폴리오 ─── */}
<section id="portfolio" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Portfolio
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
</h2>
<p
className="mt-4 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
</div>
{/* 실사례 카드 */}
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{CASES.map((c) => (
<div
key={c.t}
className="flex flex-col rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
style={
c.live
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
}
>
{c.live && (
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
)}
{c.cat}
</span>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{c.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{c.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{c.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface-alt)',
...KOR_BODY,
}}
>
{tag}
</span>
))}
</div>
</div>
))}
</div>
{/* 웹사이트 샘플 링크 */}
<div className="mt-14">
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
<div className="mt-6 grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
{SAMPLES.map((s) => (
<Link
key={s.slug}
href={`/work/website/samples/${s.slug}`}
className="group flex flex-col rounded-2xl p-6 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
{s.tag}
</span>
<h4
className="mt-3 text-base font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h4>
<p
className="mt-1 text-sm break-keep"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
{s.sub}
</p>
<span
className="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
))}
</div>
</div>
</div>
</section>
{/* ─── 5. FAQ ─── */}
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="mb-12">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
FAQ
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
</div>
<div className="space-y-3">
{FAQ.map((item) => (
<details
key={item.q}
className="group rounded-2xl border overflow-hidden"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<summary
className="flex items-center justify-between gap-4 cursor-pointer list-none px-6 py-5 font-semibold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{item.q}
<svg
className="shrink-0 transition-transform duration-200 group-open:rotate-45"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
style={{ color: 'var(--jsm-ink-faint)' }}
>
<path d="M12 5v14M5 12h14" />
</svg>
</summary>
<p
className="px-6 pb-5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{item.a}
</p>
</details>
))}
</div>
</div>
</section>
{/* ─── 6. 의뢰 폼 ─── */}
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="grid lg:grid-cols-5 gap-10 lg:gap-12">
{/* 안내 */}
<div className="lg:col-span-2">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: '#7aa7ff' }}
>
Contact
</p>
<h2
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
style={KOR_TIGHT}
>
</h2>
<p
className="mt-5 text-lg leading-relaxed text-white/70 break-keep"
style={KOR_BODY}
>
2 .
.
</p>
<div
className="mt-8 pt-8 border-t space-y-3"
style={{ borderColor: 'rgba(255,255,255,0.12)' }}
>
<a
href="mailto:bgg8988@gmail.com"
className="flex items-center gap-3 text-sm text-white/80 hover:text-white transition-colors"
style={KOR_BODY}
>
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Mail</span>
bgg8988@gmail.com
</a>
<a
href="tel:010-3907-1392"
className="flex items-center gap-3 text-sm text-white/80 hover:text-white transition-colors"
style={KOR_BODY}
>
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Tel</span>
010-3907-1392
</a>
</div>
</div>
{/* 폼 */}
<div className="lg:col-span-3">
<div
className="rounded-2xl p-6 lg:p-8"
style={{ background: 'var(--jsm-surface)' }}
>
<ContactForm />
</div>
</div>
</div>
</div>
</section>
</>
);
}

View File

@@ -1,4 +1,6 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'SaaS 제품 · 월 구독 패키지',
@@ -13,6 +15,7 @@ export const metadata: Metadata = {
},
};
export default function PackagesLayout({ children }: { children: React.ReactNode }) {
return children;
export default async function PackagesLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('packages'))) notFound();
return <>{children}</>;
}

View File

@@ -1,461 +1,532 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from './components/ContactModal';
import { GlassButton } from './components/LiquidGlass';
import { trackCTAClick } from '@/lib/gtag';
import { PORTFOLIO } from '@/lib/freelance-portfolio';
import { createAdminClient } from '@/lib/supabase/admin';
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
const BEFORE = [
'작곡 공부에만 최소 6개월 소요',
'영상 편집 프로그램 학습의 높은 장벽',
'항상 불안한 저작권 위반 위험',
'곡 하나 완성에 드는 수백만 원의 외주비',
// 쟁승메이드 메인 — 외주 개발 + 완성 소프트웨어 2축 랜딩 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
// 소프트웨어 진열 섹션이 DB 조회를 포함하므로 항상 최신 목록을 보여준다.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const PROCESS = [
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다.' },
{ n: '02', t: '견적·범위 확정', d: '영업일 2일 내 범위와 견적을 정리해 회신드립니다.' },
{ n: '03', t: '개발·중간 공유', d: '주 1회 이상 진행 상황을 공유하며 방향을 맞춥니다.' },
{ n: '04', t: '납품·배포 지원', d: '검수 후 30일 무상 하자보수로 안정화까지 책임집니다.' },
];
const AFTER = [
'단 1시간 만에 프로급 음원 & 영상 완성',
'드래그 앤 드롭 수준의 직관적인 워크플로우',
'가이드대로 따라하면 완벽한 저작권 해결',
'커피 한 잔 가격으로 무한대 콘텐츠 생산',
const STATS = [
{ v: '7년차', l: '대기업 백엔드 개발 경력' },
{ v: '15+', l: '직접 운영 중인 서비스' },
{ v: '기획→배포', l: '원스톱 단독 진행' },
];
const TWEETS_ROW_A = [
{ name: '김민재', handle: 'minjae_shorts', time: '2h', body: '작곡 하나 못 하던 내가 3일 만에 쇼츠 채널 열었다. 프롬프트북 반칙 수준 ㄹㅇ' },
{ name: '이소영', handle: 'cafe_sohyang', time: '5h', body: '매장 BGM 직접 만들어요. 저작권 고민 없이 매달 플레이리스트 갈아끼우는 게 신기함.' },
{ name: '박도현', handle: 'dohyun_side', time: '1d', body: '퇴근 후 1시간 = 쇼츠 한 편. 애드센스 첫 수익이 3주 만에 꽂혔습니다. 팩값 회수 완료.' },
{ name: '정유진', handle: 'yujin_indie', time: '2d', body: '데모 작업 시간이 1/5로. 레퍼런스 탐색 → MV까지 한 번에. 인디 뮤지션들 다 써야 함.' },
{ name: '최현우', handle: 'hyunwoo_tube', time: '3d', body: '구독자 정체기였는데 AI 뮤비 시리즈로 알고리즘 탑승. 조회수 월 +320%.' },
{ name: '한지원', handle: 'jiwon_studio', time: '4d', body: '팩 안에 든 저작권 체크리스트가 실질적. Suno 약관 읽는 시간 아꼈다.' },
{ name: '오세린', handle: 'serin_mv', time: '5d', body: 'Runway 프리셋 그대로 써도 퀄 나옴. 프롬프트 설계가 반이네요.' },
{ name: '강태윤', handle: 'taeyun_ads', time: '6d', body: '광고 BGM 10개 찍어서 외주 드렸더니 클라이언트 반응이 달라졌습니다.' },
const STACK = ['Python', 'Java', 'Spring', 'Next.js', 'AI 연동'];
const PORTFOLIO = [
{
t: '주식 자동매매 시스템',
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
tags: ['실시간 주문', '텔레그램 연동', '리포트 자동화'],
},
{
t: '부동산 청약 자동 수집·매칭',
d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 알립니다.',
tags: ['크롤링', '조건 매칭', '푸시 알림'],
},
{
t: 'AI 콘텐츠 자동화 파이프라인',
d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.',
tags: ['AI 연동', '검수 워크플로우', '자동 발행'],
},
];
const TWEETS_ROW_B = [
{ name: '문가은', handle: 'gaeun_beats', time: '3h', body: '가사 생성 템플릿이 진짜 핵심. 한글 랩 가사 붙일 때 막히던 거 뚫렸어요.' },
{ name: '류현석', handle: 'hyun_creator', time: '7h', body: '쇼츠 업로드 루틴이 1시간 안에 끝남. 주말마다 10편씩 쌓고 있습니다.' },
{ name: '배수진', handle: 'sujin_pop', time: '1d', body: 'K-POP 스타일 프롬프트 조합 충격. 레퍼런스 없이도 그 느낌이 나옴.' },
{ name: '송재훈', handle: 'jaehun_lab', time: '2d', body: '1:1 Q&A 답변 속도 미쳤어요. 당일 회신 + 실무 디테일까지.' },
{ name: '조은비', handle: 'eunbi_vlog', time: '3d', body: '브이로그 BGM 자작하니까 조회수 + 체류시간 둘 다 올라감. 데이터가 말함.' },
{ name: '신도윤', handle: 'doyoon_snd', time: '4d', body: '스템 분리본이 포함된 게 진짜 크다. 믹싱 작업 훨씬 편해짐.' },
{ name: '윤채원', handle: 'chaewon_art', time: '5d', body: 'Midjourney 프롬프트 풀 가치가 팩값 넘음. 그냥 사세요.' },
{ name: '임준혁', handle: 'junhyuk_tune', time: '6d', body: '업데이트 진짜로 오네요. 2주 만에 V4.5 프롬프트 가이드 추가됨.' },
];
const CB_CARDS = [
{ href: '/work/freelance', label: '외주 개발', desc: '맞춤 솔루션 · RPA·API 자동화 포함', key: 'freelance' },
{ href: '/work/website', label: '웹사이트', desc: '기업·브랜드 사이트', key: 'website' },
{ href: '/work/saju', label: 'AI 사주', desc: '12개 항목 무료 해석', key: 'saju' },
];
export default function Home() {
const [modalOpen, setModalOpen] = useState(false);
const [modalService, setModalService] = useState('일반 문의');
const openContact = (service: string) => {
setModalService(service);
setModalOpen(true);
};
function ArrowRight() {
return (
<div className="relative overflow-x-hidden bg-black text-white">
<ContactModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setModalService('일반 문의');
}}
service={modalService}
checklist={['연락처/이메일', '원하는 작업 범위', '희망 일정']}
/>
{/* 1. Brand Hero — kx-surface 검정, 60vh, 텍스트 중심 */}
<section
className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10 overflow-hidden"
style={{ background: 'var(--kx-surface)' }}
>
<video
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
src="/hero-bg.mp4"
autoPlay
loop
muted
playsInline
preload="auto"
aria-hidden
style={{ filter: 'blur(8px)', opacity: 0.35 }}
/>
<div className="absolute inset-0 bg-black/40 pointer-events-none" aria-hidden />
<div className="relative z-10 max-w-3xl mx-auto text-center">
<h1
className="kx-display text-4xl md:text-6xl lg:text-7xl font-bold mb-5 leading-[1.1]"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
<br /> .
</h1>
<p className="text-base md:text-xl text-white/70 leading-relaxed">
SaaS로. AI .
</p>
</div>
</section>
{/* 2. Two-up Cards */}
<section className="py-20 px-6 bg-black border-b border-white/10">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Music 카드 */}
<Link
href="/music"
onClick={() => trackCTAClick('home_v7_card_music')}
className="group relative rounded-2xl border border-white/15 overflow-hidden min-h-[280px] flex flex-col justify-end p-8 hover:border-white/40 transition"
style={{ textDecoration: 'none' }}
>
<video
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
src="/hero-bg.mp4"
autoPlay
loop
muted
playsInline
preload="auto"
aria-hidden
style={{ opacity: 0.5 }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-transparent pointer-events-none" />
<div className="relative z-10">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/60 mb-3">
Music
</p>
<h2 className="kx-display text-2xl md:text-3xl font-bold text-white mb-2">
AI
</h2>
<p className="text-sm md:text-base text-white/70 mb-4">
Suno + + SEO .
</p>
<p className="font-mono text-xs text-white mb-5">39,000~</p>
<span className="inline-flex items-center gap-2 text-sm font-bold text-white">
Try now <span aria-hidden></span>
</span>
</div>
</Link>
{/* 커스텀 외주 카드 */}
<Link
href="/work"
onClick={() => trackCTAClick('home_v7_card_work')}
className="group relative rounded-2xl border border-white/15 overflow-hidden min-h-[280px] flex flex-col justify-end p-8 hover:border-white/40 transition"
style={{
textDecoration: 'none',
background: 'linear-gradient(135deg, var(--kx-surface) 0%, rgba(204,151,255,0.15) 100%)',
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 40px)',
}}
>
<div className="relative z-10">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/60 mb-3">
Custom Work
</p>
<h2 className="kx-display text-2xl md:text-3xl font-bold text-white mb-2">
</h2>
<p className="text-sm md:text-base text-white/70 mb-4">
· · AI
</p>
<p className="text-xs text-white/50 mb-5"> 5 · 24h </p>
<span className="inline-flex items-center gap-2 text-sm font-bold text-white">
<span aria-hidden></span>
</span>
</div>
</Link>
</div>
</section>
{/* 3. Music 섹션 — 기존 Features + Before/After + Tweet 마퀴 보존 */}
{/* 3-1. Features */}
<section className="py-24 px-6 bg-white text-black border-b border-black/10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-20">
<p className="font-mono text-[11px] tracking-widest uppercase text-black/50 mb-4">
Features
</p>
<h2
className="kx-display text-3xl md:text-5xl font-bold text-black"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
3 .
</h2>
</div>
<div className="space-y-20 lg:space-y-28">
{[
{
label: '01 · Prompt',
title: '프롬프트 한 줄이 곡이 됩니다.',
desc: '장르·무드·보컬 톤을 조합한 20+종 프롬프트 북. 복붙 한 번으로 Suno가 이해하는 구조로 변환됩니다.',
video: '/feature-prompt.mp4',
},
{
label: '02 · Visual',
title: '비트에 맞춰 영상이 붙습니다.',
desc: 'Midjourney·Runway·Luma 워크플로우. 가사와 비트를 싱크하는 9:16 쇼츠·16:9 풀버전을 바로 뽑아낼 수 있습니다.',
video: '/feature-visual.mp4',
},
{
label: '03 · Publish',
title: '업로드 직전까지 마무리됩니다.',
desc: '유튜브 제목·해시태그·설명란 SEO 템플릿. 복사-붙여넣기만으로 첫 쇼츠가 당일 채널에 올라갑니다.',
video: null,
},
].map((f, i) => {
const reverse = i % 2 === 1;
return (
<div
key={f.label}
className={`grid lg:grid-cols-2 gap-10 lg:gap-16 items-center ${reverse ? 'lg:[&>*:first-child]:order-2' : ''}`}
>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-black/50 mb-4">
{f.label}
</p>
<h3
className="kx-display text-2xl md:text-4xl font-bold mb-5 text-black"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
{f.title}
</h3>
<p className="text-base md:text-lg text-black/70 leading-relaxed max-w-lg">
{f.desc}
</p>
</div>
<div className="relative aspect-video rounded-2xl border border-black/15 bg-black/5 overflow-hidden">
{f.video ? (
<video
className="absolute inset-0 w-full h-full object-cover"
src={f.video}
autoPlay
loop
muted
playsInline
preload="auto"
aria-hidden
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="font-mono text-[11px] tracking-widest uppercase text-black/40">
Video Placeholder
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</section>
{/* 3-2. Before / After */}
<section className="py-24 px-6 bg-black text-white border-b border-white/10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-14">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
Efficiency
</p>
<h2
className="kx-display text-3xl md:text-5xl font-bold"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
.
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="p-8 rounded-2xl border border-white/15 bg-white/[0.02]">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-3">
Manual Process
</p>
<h3 className="text-2xl font-bold mb-6 text-white/60">Before</h3>
<ul className="space-y-3">
{BEFORE.map((t) => (
<li key={t} className="flex items-start gap-3 text-sm text-white/60">
<span className="text-white/40">·</span>
<span>{t}</span>
</li>
))}
</ul>
</div>
<div className="p-8 rounded-2xl border border-white bg-white text-black">
<p className="font-mono text-[11px] tracking-widest uppercase text-black/60 mb-3">
AI Powered
</p>
<h3 className="text-2xl font-bold mb-6">After</h3>
<ul className="space-y-3">
{AFTER.map((t) => (
<li key={t} className="flex items-start gap-3 text-sm text-black/80">
<span className="text-black/50">·</span>
<span>{t}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
{/* 3-3. Use Cases — Tweet Marquee */}
<section className="py-24 bg-white text-black border-b border-black/10 overflow-hidden">
<div className="px-6 max-w-6xl mx-auto">
<div className="text-center mb-14">
<p className="font-mono text-[11px] tracking-widest uppercase text-black/50 mb-4">
Use Cases
</p>
<h2
className="kx-display text-3xl md:text-5xl font-bold"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
.
</h2>
</div>
</div>
<div className="space-y-5 marquee-mask">
{[TWEETS_ROW_A, TWEETS_ROW_B].map((row, rowIdx) => (
<div key={rowIdx} className="marquee-viewport overflow-hidden">
<div className={`marquee-track ${rowIdx === 1 ? 'marquee-track-reverse' : ''}`}>
{[...row, ...row].map((t, i) => (
<article
key={`${rowIdx}-${i}`}
className="shrink-0 w-[320px] sm:w-[360px] p-5 rounded-2xl border border-black/15 bg-black/[0.02]"
>
<header className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-black/5 border border-black/15 flex items-center justify-center font-bold text-black">
{t.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-black text-sm truncate">{t.name}</p>
<p className="font-mono text-[11px] text-black/50 truncate">@{t.handle}</p>
</div>
<span className="font-mono text-[10px] text-black/40 shrink-0">{t.time}</span>
</header>
<p
className="text-sm leading-relaxed text-black/80"
style={{ wordBreak: 'keep-all' }}
>
{t.body}
</p>
</article>
))}
</div>
</div>
))}
</div>
</section>
{/* 4. 커스텀 외주 섹션 — 카드 + 5건 사례 + 견적 CTA */}
<section className="py-24 px-6 bg-black text-white border-b border-white/10">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-14">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
Custom Work
</p>
<h2
className="kx-display text-3xl md:text-5xl font-bold mb-5"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
?
</h2>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
7 ··. , , AI .
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
{CB_CARDS.map((card) => (
<Link
key={card.key}
href={card.href}
onClick={() => trackCTAClick(`home_v7_cb_card_${card.key}`)}
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-5 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col"
>
<p className="font-bold text-white text-sm mb-1.5">{card.label}</p>
<p className="text-xs text-white/60 leading-relaxed flex-1">{card.desc}</p>
<span aria-hidden="true" className="mt-3 text-white/40 text-xs"></span>
</Link>
))}
</div>
{/* 납품 5건 사례 미리보기 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-12">
{PORTFOLIO.map((p) => (
<div
key={p.title}
className={`p-4 rounded-2xl border ${p.borderAccent} ${p.accentBg} flex flex-col`}
>
<p className={`font-mono text-[9px] uppercase tracking-widest ${p.accentColor} mb-2`}>
{p.category}
</p>
<h3 className="font-bold text-white text-xs leading-tight mb-1.5">{p.title}</h3>
<p className="text-[10px] text-white/50 line-clamp-2 flex-1">{p.result}</p>
</div>
))}
</div>
<div className="text-center">
<button
onClick={() => {
trackCTAClick('home_v7_cb_cta');
openContact('외주 개발 문의');
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm"
>
</button>
</div>
</div>
</section>
{/* 5. Final CTA — 어느 쪽이든 시작하세요 */}
<section className="relative w-full min-h-[400px] flex items-center justify-center px-6 py-24 bg-black overflow-hidden">
<video
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
src="/hero-bg.mp4"
autoPlay
loop
muted
playsInline
preload="auto"
aria-hidden
style={{ filter: 'blur(8px)', opacity: 0.35 }}
/>
<div className="absolute inset-0 bg-black/50 pointer-events-none" />
<div className="relative z-10 max-w-2xl mx-auto text-center">
<h2
className="kx-display text-3xl md:text-5xl font-bold mb-8"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
.
</h2>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<GlassButton
href="/music"
onClick={() => trackCTAClick('home_v7_final_music')}
tint="rgba(255,255,255,0.18)"
className="text-base"
>
<span className="text-white">Music </span>
</GlassButton>
<button
onClick={() => {
trackCTAClick('home_v7_final_work');
openContact('외주 개발 문의');
}}
className="kx-btn-primary inline-flex items-center justify-center px-7 py-3 rounded-full text-base"
>
</button>
</div>
</div>
</section>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
async function loadFeaturedProducts(): Promise<ProductRow[]> {
try {
const all = await getListedProducts(createAdminClient());
return all.slice(0, 3);
} catch (err) {
console.error('[Home] getListedProducts failed, falling back to empty:', err);
return [];
}
}
export default async function Home() {
const featuredProducts = await loadFeaturedProducts();
const hasProducts = featuredProducts.length > 0;
return (
<>
{/* ─── 1. Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
<div className="max-w-3xl">
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-accent)',
background: 'var(--jsm-accent-soft)',
...KOR_BODY,
}}
>
·
</span>
<h1
className="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
<br className="hidden sm:block" /> {' '}
<span style={{ color: 'var(--jsm-accent)' }}> .</span>
</h1>
<p
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
7 ··.
.
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-3">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
<Link
href="/products"
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
>
</Link>
</div>
</div>
</div>
</section>
{/* ─── 2. 2축 서비스 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="grid md:grid-cols-2 gap-6">
{/* 외주 개발 */}
<Link
href="/outsourcing"
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
Custom
</span>
<h2
className="mt-3 text-2xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-3 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
· . , API, ,
.
</p>
<span
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
{/* 완성 소프트웨어 */}
<Link
href="/products"
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
Ready-made
</span>
<h2
className="mt-3 text-2xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-3 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
.
</p>
<span
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</Link>
</div>
</div>
</section>
{/* ─── 3. 개발 프로세스 ─── */}
<section id="process" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Process
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
,
</h2>
</div>
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-4 gap-px rounded-2xl overflow-hidden border" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}>
{PROCESS.map((s) => (
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
<span
className="text-sm font-bold"
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
>
{s.n}
</span>
<h3
className="mt-4 text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{s.t}
</h3>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{s.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── 4. 신뢰 요소 ─── */}
<section style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-24">
<div className="grid sm:grid-cols-3 gap-10 sm:gap-8">
{STATS.map((s) => (
<div key={s.l}>
<p
className="text-3xl lg:text-4xl font-bold text-white"
style={KOR_TIGHT}
>
{s.v}
</p>
<p
className="mt-2 text-sm leading-relaxed break-keep text-white/60"
style={KOR_BODY}
>
{s.l}
</p>
</div>
))}
</div>
<div
className="mt-12 pt-8 border-t flex flex-wrap items-center gap-x-3 gap-y-2"
style={{ borderColor: 'rgba(255,255,255,0.1)' }}
>
<span className="text-xs uppercase tracking-wider text-white/40 mr-1">Stack</span>
{STACK.map((s) => (
<span
key={s}
className="text-sm text-white/80 px-3 py-1 rounded-full"
style={{ background: 'rgba(255,255,255,0.06)', ...KOR_BODY }}
>
{s}
</span>
))}
</div>
</div>
</section>
{/* ─── 5. 포트폴리오 하이라이트 ─── */}
<section id="portfolio" style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Portfolio
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-4 leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
</div>
<div className="mt-12 grid md:grid-cols-3 gap-6">
{PORTFOLIO.map((p) => (
<div
key={p.t}
className="flex flex-col rounded-2xl p-7 border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
·
</span>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.t}
</h3>
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{p.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface-alt)',
...KOR_BODY,
}}
>
{tag}
</span>
))}
</div>
</div>
))}
</div>
<div className="mt-10">
<Link
href="/outsourcing#portfolio"
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
</section>
{/* ─── 6. 소프트웨어 진열 ─── */}
{/* Phase 2: products 테이블 기반 동적 진열. 0개이면 출시 준비 중 폴백. */}
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
{hasProducts ? (
<>
<div className="flex items-end justify-between mb-10">
<div>
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Software
</p>
<h2
className="text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
</div>
<Link
href="/products"
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] shrink-0"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
<div className="grid md:grid-cols-3 gap-6">
{featuredProducts.map((p) => (
<Link
key={p.id}
href={`/products/${p.id}`}
className="group flex flex-col rounded-2xl p-7 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h3
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.name}
</h3>
{p.description && (
<p
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.description}
</p>
)}
<div
className="mt-6 pt-5 flex items-center justify-between border-t"
style={{ borderColor: 'var(--jsm-line)' }}
>
<span
className="text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
&#8361;{p.price.toLocaleString('ko-KR')}
</span>
<span
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</div>
</Link>
))}
</div>
<div className="mt-8 sm:hidden">
<Link
href="/products"
className="inline-flex items-center gap-1.5 text-sm font-semibold"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</>
) : (
<div
className="rounded-2xl border px-8 py-14 lg:px-14 lg:py-16 text-center"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<p
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
Coming soon
</p>
<h2
className="text-2xl lg:text-3xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-4 max-w-xl mx-auto leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
.
</p>
<Link
href="/outsourcing#contact"
className="mt-8 inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
style={{
color: 'var(--jsm-ink)',
borderColor: 'var(--jsm-line)',
...KOR_BODY,
}}
>
<ArrowRight />
</Link>
</div>
)}
</div>
</section>
{/* ─── 7. 최종 CTA ─── */}
<section style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-28">
<div className="max-w-3xl">
<h2
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
style={KOR_TIGHT}
>
,
</h2>
<p
className="mt-5 text-lg leading-relaxed text-white/70 break-keep max-w-2xl"
style={KOR_BODY}
>
. .
</p>
<Link
href="/outsourcing#contact"
className="mt-9 inline-flex items-center justify-center gap-2 px-7 py-4 rounded-lg font-semibold text-white transition-colors duration-150"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
</section>
</>
);
}

View File

@@ -19,7 +19,7 @@ function FailContent() {
<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 text-[#04102b] mb-2">
<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>
@@ -45,9 +45,9 @@ 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="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<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 bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
<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>

View File

@@ -18,7 +18,7 @@ function SuccessContent() {
<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 text-[#04102b] mb-2"> !</h2>
<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>
)}
@@ -47,9 +47,9 @@ 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="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<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 bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
<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>

View File

@@ -23,69 +23,78 @@ export default async function PortfolioGateway({ params }: Props) {
const expires = new Date(payload.exp).toLocaleDateString('ko-KR');
return (
<div className="min-h-screen bg-slate-950 text-white">
<section
className="relative overflow-hidden px-6 py-20 lg:px-14 lg:py-28"
style={{
background:
'radial-gradient(circle at 30% 20%, #1e293b 0%, #020617 55%)',
}}
>
<div className="min-h-screen" style={{ background: 'var(--jsm-bg)' }}>
{/* 헤더 배너 — jsm-navy 사용 (푸터/다크 섹션 전용 토큰) */}
<div className="px-6 py-4" style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-4xl mx-auto flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm" style={{ background: 'var(--jsm-accent)' }}>
</div>
<span className="text-white font-bold text-sm"></span>
<span className="ml-auto font-mono text-xs tracking-[0.2em] uppercase px-3 py-1 rounded-full border" style={{ color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)' }}>
Private · {payload.memo || 'Confidential'}
</span>
</div>
</div>
<section className="px-6 py-16 lg:px-14 lg:py-24">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="font-mono text-xs text-emerald-300/80 tracking-[0.25em] uppercase">
Private Portfolio · {payload.memo || 'Confidential'}
<div className="flex items-center gap-2 mb-6">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs font-semibold uppercase tracking-widest" style={{ color: 'var(--jsm-ink-soft)' }}>
</span>
</div>
<h1 className="text-4xl md:text-6xl font-extrabold leading-[1.05] mb-6" style={{ wordBreak: 'keep-all' }}>
<h1 className="text-4xl md:text-5xl font-extrabold leading-tight mb-4" style={{ color: 'var(--jsm-ink)', wordBreak: 'keep-all' }}>
<br />
<span className="bg-gradient-to-r from-sky-300 via-blue-200 to-cyan-300 bg-clip-text text-transparent">
</span>
<span className="gradient-text"> </span>
</h1>
<p className="text-slate-300 text-lg leading-relaxed max-w-2xl mb-10" style={{ wordBreak: 'keep-all' }}>
<p className="text-lg leading-relaxed max-w-2xl mb-4" style={{ color: 'var(--jsm-ink-soft)', wordBreak: 'keep-all' }}>
· · · 100% .
{expires} .
</p>
<p className="text-sm mb-10 font-mono px-3 py-2 rounded-lg inline-block" style={{ color: 'var(--jsm-ink-faint)', background: 'var(--jsm-surface-alt)', border: '1px solid var(--jsm-line)' }}>
{expires}
</p>
<div className="grid sm:grid-cols-2 gap-4">
<Link
href="/freelance"
className="group border border-white/10 hover:border-sky-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
>
<p className="font-mono text-xs text-sky-300/70 uppercase tracking-widest mb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: 'var(--jsm-accent)' }}>
Freelance
</p>
<h3 className="text-xl font-extrabold mb-2"> · </h3>
<p className="text-sm text-slate-400 leading-relaxed">
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}> · </h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
, , , .
</p>
<span className="inline-block mt-4 text-sm font-bold text-sky-300 group-hover:underline">
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: 'var(--jsm-accent)' }}>
</span>
</Link>
<Link
href="/services/website"
className="group border border-white/10 hover:border-violet-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
>
<p className="font-mono text-xs text-violet-300/70 uppercase tracking-widest mb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: '#7c3aed' }}>
Website
</p>
<h3 className="text-xl font-extrabold mb-2">· </h3>
<p className="text-sm text-slate-400 leading-relaxed">
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}>· </h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Next.js , SEO , 3 .
</p>
<span className="inline-block mt-4 text-sm font-bold text-violet-300 group-hover:underline">
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: '#7c3aed' }}>
</span>
</Link>
</div>
<div className="mt-10 text-xs text-slate-500 font-mono">
<div className="mt-10 text-xs font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
© · 010-3907-1392 · bgg8988@gmail.com
</div>
</div>

View File

@@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
import BankTransferModal from '@/app/components/BankTransferModal';
// 상세 페이지의 구매 버튼 + 모달 트리거 (클라이언트 경계).
// 서버 페이지가 product 요약만 넘겨주고, 주문 금액은 서버(API)가 DB로 확정한다.
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
interface Props {
product: { id: string; name: string; price: number };
}
export default function BuySection({ product }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center w-full sm:w-auto px-8 py-3.5 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</button>
<BankTransferModal
product={product}
isOpen={open}
onClose={() => setOpen(false)}
/>
</>
);
}

171
app/products/[id]/page.tsx Normal file
View File

@@ -0,0 +1,171 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById, type ProductRow } from '@/lib/supabase/product-files';
import BuySection from './BuySection';
// 완성 소프트웨어 상세 (서버 컴포넌트).
// 비노출/비활성/존재하지 않음/DB 예외 → notFound() 로 일관 처리해 500을 내지 않는다.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
interface Props {
params: Promise<{ id: string }>;
}
async function loadProduct(id: string): Promise<ProductRow | null> {
try {
return await getProductById(createAdminClient(), id);
} catch (err) {
// DB 장애·마이그레이션 미적용 등 — 상세 페이지는 404로 폴백
console.error('[ProductDetail] getProductById failed:', err);
return null;
}
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await loadProduct(id);
if (!product || !product.is_listed || !product.is_active) {
return { title: '완성 소프트웨어' };
}
return {
title: product.name,
description:
product.description ??
`${product.name} — 쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어. 입금 확인 후 마이페이지에서 즉시 다운로드.`,
};
}
function CheckMark() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 mt-0.5"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
export default async function ProductDetailPage({ params }: Props) {
const { id } = await params;
const product = await loadProduct(id);
if (!product || !product.is_listed || !product.is_active) {
notFound();
}
const features = product.features ?? [];
const longText = product.description_long ?? product.description ?? '';
return (
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
{/* 브레드크럼 */}
<nav className="mb-8" aria-label="breadcrumb">
<Link
href="/products"
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-[var(--jsm-accent)]"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m15 18-6-6 6-6" />
</svg>
</Link>
</nav>
{/* 제품명 · 가격 */}
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<h1
className="text-2xl sm:text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.name}
</h1>
<p
className="mt-4 text-2xl font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.price.toLocaleString('ko-KR')}
</p>
</header>
{/* 상세 설명 */}
{longText && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<p
className="text-base leading-relaxed break-keep whitespace-pre-line"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{longText}
</p>
</div>
)}
{/* 기능 리스트 */}
{features.length > 0 && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<h2
className="text-sm font-semibold mb-4 uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
</h2>
<ul className="space-y-3">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2.5 text-sm sm:text-base break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>
<CheckMark />
</span>
<span>{f}</span>
</li>
))}
</ul>
</div>
)}
{/* 구매 안내 + CTA */}
<div className="pt-8">
<div
className="rounded-lg border px-4 py-3.5 mb-6"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
( ).
</p>
</div>
<BuySection
product={{ id: product.id, name: product.name, price: product.price }}
/>
<p className="mt-5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{' '}
<Link href="/legal/refund" className="underline" style={{ color: 'var(--jsm-ink-soft)' }}>
</Link>
.
</p>
</div>
</div>
</section>
);
}

287
app/products/page.tsx Normal file
View File

@@ -0,0 +1,287 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { createAdminClient } from '@/lib/supabase/admin';
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트).
// DB 장애·마이그레이션 미적용 시 빈 배열로 폴백해 페이지가 항상 200으로 생존한다.
export const metadata: Metadata = {
title: '완성 소프트웨어',
description:
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
};
// 카탈로그는 항상 최신 상품을 보여주도록 동적 렌더링.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const HOW = [
{ n: '01', t: '계좌이체 신청', d: '구매할 도구를 고르고 입금자명과 함께 신청합니다.' },
{ n: '02', t: '입금 확인', d: '입금이 확인되면 승인합니다. 최대 24시간 내 처리됩니다.' },
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지의 내 제품에서 파일을 바로 내려받습니다.' },
];
function ArrowRight() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
function CheckMark() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 mt-0.5"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
async function loadProducts(): Promise<ProductRow[]> {
try {
return await getListedProducts(createAdminClient());
} catch (err) {
// DB 장애·컬럼 미존재(마이그레이션 미적용) 등 — 페이지는 준비 중 폴백으로 생존
console.error('[Products] getListedProducts failed, falling back to empty:', err);
return [];
}
}
export default async function ProductsPage() {
const products = await loadProducts();
const hasProducts = products.length > 0;
return (
<>
{/* ─── Hero ─── */}
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<div className="max-w-2xl">
<span
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
style={{
color: 'var(--jsm-accent)',
background: 'var(--jsm-accent-soft)',
...KOR_BODY,
}}
>
</span>
<h1
className="text-3xl sm:text-4xl lg:text-5xl font-bold leading-[1.2] break-keep mb-5"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
<br />
.
</h1>
<p
className="text-base sm:text-lg leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
</div>
</div>
</section>
{/* ─── 카탈로그 / 준비 중 ─── */}
{hasProducts ? (
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{products.map((p) => {
const features = (p.features ?? []).slice(0, 3);
return (
<Link
key={p.id}
href={`/products/${p.id}`}
className="group flex flex-col rounded-2xl p-7 lg:p-8 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2
className="text-xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.name}
</h2>
{p.description && (
<p
className="mt-2.5 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{p.description}
</p>
)}
{features.length > 0 && (
<ul className="mt-5 space-y-2">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2 text-sm break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>
<CheckMark />
</span>
<span>{f}</span>
</li>
))}
</ul>
)}
<div className="mt-6 pt-5 flex items-center justify-between border-t" style={{ borderColor: 'var(--jsm-line)' }}>
<span
className="text-lg font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{p.price.toLocaleString('ko-KR')}
</span>
<span
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</span>
</div>
</Link>
);
})}
</div>
</div>
</section>
) : (
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div
className="rounded-lg border p-8 text-center"
style={{
background: 'var(--jsm-surface-alt)',
borderColor: 'var(--jsm-line)',
}}
>
<p
className="text-sm font-semibold mb-3"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
</p>
<p
className="text-xl sm:text-2xl font-bold mb-4 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
.
</p>
<p
className="text-sm sm:text-base leading-relaxed break-keep max-w-md mx-auto"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
,
.
.
</p>
</div>
</div>
</section>
)}
{/* ─── 구매 방식 안내 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<h2
className="text-xl sm:text-2xl font-bold mb-10 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{HOW.map((step) => (
<div
key={step.n}
className="rounded-lg border p-6"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold mb-3 block"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{step.n}
</span>
<p
className="font-bold mb-2 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{step.t}
</p>
<p
className="text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{step.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── CTA ─── */}
<section className="border-t" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
<div className="flex flex-col sm:flex-row gap-4">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg text-sm font-semibold transition-colors"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
...KOR_BODY,
}}
>
{hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}
</Link>
<Link
href="/outsourcing"
className="inline-flex items-center justify-center gap-2 px-6 py-3 rounded-lg border text-sm font-semibold transition-colors"
style={{
borderColor: 'var(--jsm-line)',
color: 'var(--jsm-ink-soft)',
background: 'var(--jsm-surface)',
...KOR_BODY,
}}
>
</Link>
</div>
</div>
</section>
</>
);
}

View File

@@ -20,8 +20,8 @@ interface Quote {
}
const CATEGORY_COLORS: Record<string, string> = {
: '#60a5fa', : '#f472b6', : '#34d399', : '#fb923c',
: '#a78bfa', : '#94a3b8',
: '#2563eb', : '#db2777', : '#059669', : '#ea580c',
: '#7c3aed', : '#64748b',
};
export default function QuotePage() {
@@ -100,10 +100,10 @@ export default function QuotePage() {
if (loading) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ width: 40, height: 40, border: '3px solid rgba(99,102,241,0.3)', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}> ...</p>
<div style={{ width: 40, height: 40, border: '3px solid var(--jsm-accent-soft)', borderTopColor: 'var(--jsm-accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}> ...</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
@@ -112,29 +112,29 @@ export default function QuotePage() {
if (notFound || !quote) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
<div style={{ fontSize: 64 }}>🔍</div>
<h1 style={{ color: 'white', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}> </h1>
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}> </p>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}> </h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}> </p>
</div>
);
}
if (submitted) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
<style>{`@keyframes pop { 0% { transform: scale(0.5); opacity: 0; } 70% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }`}</style>
<div style={{ fontSize: 80, animation: 'pop 0.5s ease forwards' }}>🎉</div>
<h1 style={{ color: 'white', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}> !</h1>
<p style={{ color: '#94a3b8', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}> !</h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
.<br />
.
</p>
<div style={{ background: '#0f172a', border: '1px solid rgba(99,102,241,0.3)', borderRadius: 16, padding: '24px 32px', textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}> </div>
<div style={{ color: 'white', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}</div>
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-accent-soft)', borderRadius: 16, padding: '24px 32px', textAlign: 'center', boxShadow: '0 4px 20px rgba(29,78,216,0.08)' }}>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}> </div>
<div style={{ color: 'var(--jsm-ink)', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}</div>
{maintenanceTotal > 0 && (
<div style={{ color: '#6366f1', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ {maintenanceTotal.toLocaleString()}/</div>
<div style={{ color: 'var(--jsm-accent)', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ {maintenanceTotal.toLocaleString()}/</div>
)}
</div>
</div>
@@ -149,13 +149,12 @@ export default function QuotePage() {
].filter((t) => t.show !== false);
return (
<div style={{ background: '#0a0f1e', minHeight: '100vh', color: 'white', fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif" }}>
<div style={{ background: 'var(--jsm-bg)', minHeight: '100vh', color: 'var(--jsm-ink)', fontFamily: "'Pretendard Variable', Pretendard, 'Noto Sans KR', sans-serif" }}>
<style>{`
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
* { box-sizing: border-box; }
input[type=checkbox] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
input[type=radio] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
input[type=checkbox] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
input[type=radio] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
@media print {
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; background: white !important; color: #1e293b !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
* { overflow: visible !important; }
@@ -173,17 +172,17 @@ export default function QuotePage() {
`}</style>
{/* 헤더 */}
<div style={{ background: 'linear-gradient(180deg, #0f172a 0%, #0a0f1e 100%)', borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '32px 24px 0' }}>
<div style={{ background: 'var(--jsm-navy)', borderBottom: '1px solid rgba(255,255,255,0.08)', padding: '32px 24px 0' }}>
<div style={{ maxWidth: 900, margin: '0 auto' }}>
{/* 브랜드 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 32 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700 }}></div>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700, color: 'white' }}></div>
<div>
<div style={{ color: 'white', fontWeight: 700, fontSize: 14 }}></div>
<div style={{ color: '#475569', fontSize: 11 }}>jaengseung-made.com</div>
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>jaengseung-made.com</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.3)', color: '#818cf8', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
<span style={{ background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.2)', color: 'rgba(255,255,255,0.85)', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
</span>
<button
@@ -197,13 +196,13 @@ export default function QuotePage() {
}}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.12)',
color: '#cbd5e1', fontSize: 13, fontWeight: 600,
background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.18)',
color: 'rgba(255,255,255,0.8)', fontSize: 13, fontWeight: 600,
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.14)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = '#cbd5e1'; }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.18)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; e.currentTarget.style.color = 'rgba(255,255,255,0.8)'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
@@ -220,29 +219,29 @@ export default function QuotePage() {
</h1>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{quote.client_name && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>👤</span> {quote.client_name}
</div>
)}
{quote.valid_until && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>📅</span> : {quote.valid_until.slice(0, 10)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>📄</span> : {new Date(quote.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
{/* 탭 */}
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
{tabs.map((t) => (
<button key={t.key} onClick={() => setActiveTab(t.key as typeof activeTab)}
style={{
padding: '12px 20px', fontSize: 14, fontWeight: 500, border: 'none', cursor: 'pointer',
background: 'none', color: activeTab === t.key ? '#818cf8' : '#64748b',
borderBottom: `2px solid ${activeTab === t.key ? '#6366f1' : 'transparent'}`,
background: 'none', color: activeTab === t.key ? 'white' : 'rgba(255,255,255,0.5)',
borderBottom: `2px solid ${activeTab === t.key ? 'var(--jsm-accent)' : 'transparent'}`,
transition: 'all 0.2s', marginBottom: -1,
}}>
{t.label}
@@ -255,10 +254,10 @@ export default function QuotePage() {
{/* 만료 배너 */}
{isExpired && (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '16px 24px 0' }}>
<div style={{ background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 18 }}>&#9888;</span>
<div>
<div style={{ color: '#f59e0b', fontWeight: 700, fontSize: 14 }}> </div>
<div style={{ color: '#b45309', fontWeight: 700, fontSize: 14 }}> </div>
<div style={{ color: '#92400e', fontSize: 13 }}>({quote.valid_until?.slice(0, 10)}) . .</div>
</div>
</div>
@@ -271,16 +270,16 @@ export default function QuotePage() {
{/* ── 개요 ── */}
{(isPrinting || activeTab === 'overview') && (
<div style={{ animation: 'fadeUp 0.4s ease' }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}></h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}></h2>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#60a5fa" />
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#a78bfa" />
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#34d399" />
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#2563eb" />
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#7c3aed" />
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#059669" />
<StatCard
label="선택 포함 합계"
value={grandTotal.toLocaleString() + '원'}
sub={optionalItems.filter(i => checkedOptional[i.id]).length + '개 선택됨'}
color="#f59e0b"
color="#d97706"
/>
</div>
</div>
@@ -289,16 +288,16 @@ export default function QuotePage() {
{/* ── WBS ── */}
{(isPrinting || activeTab === 'wbs') && quote.wbs.length > 0 && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>WBS ( )</h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>WBS ( )</h2>}
{quote.wbs.map((phase, pi) => (
<div key={phase.id} style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0, color: 'white' }}>
{pi + 1}
</div>
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'white' }}>{phase.phase}</h3>
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'var(--jsm-ink)' }}>{phase.phase}</h3>
</div>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '28%' }} />
@@ -306,7 +305,7 @@ export default function QuotePage() {
<col style={{ width: '60%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -314,10 +313,10 @@ export default function QuotePage() {
</thead>
<tbody>
{phase.tasks.map((task) => (
<tr key={task.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<tr key={task.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
<td style={{ ...tdStyle, wordBreak: 'keep-all' }}>{task.name}</td>
<td style={{ ...tdStyle, color: '#818cf8', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{task.description || '—'}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-accent)', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{task.description || '—'}</td>
</tr>
))}
</tbody>
@@ -331,16 +330,16 @@ export default function QuotePage() {
{/* ── 견적 항목 ── */}
{(isPrinting || activeTab === 'quote') && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}> </h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}> </h2>}
{/* 필수 항목 */}
{requiredItems.length > 0 && (
<section style={{ marginBottom: 32 }}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#60a5fa', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#60a5fa', display: 'inline-block' }} />
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#2563eb', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#2563eb', display: 'inline-block' }} />
<span style={{ background: 'linear-gradient(135deg, #ef4444, #f97316)', color: 'white', fontSize: 11, fontWeight: 700, padding: '2px 10px', borderRadius: 100, marginLeft: 4 }}>40% </span>
</h3>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflowX: 'auto' }}>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '10%' }} />
@@ -351,7 +350,7 @@ export default function QuotePage() {
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -362,17 +361,17 @@ export default function QuotePage() {
</thead>
<tbody>
{requiredItems.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<tr key={item.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: 'white' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'white', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
<td style={{ ...tdStyle, fontWeight: 600, color: 'var(--jsm-ink)' }}>{item.name}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'var(--jsm-ink)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
</tr>
))}
</tbody>
@@ -380,9 +379,9 @@ export default function QuotePage() {
</div>
{/* 필수 항목 할인 소계 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12, gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: '#64748b', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}> {requiredOriginal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}> {requiredOriginal.toLocaleString()}</span>
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>{(requiredOriginal - requiredTotal).toLocaleString()} </span>
<span style={{ color: '#34d399', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
<span style={{ color: '#059669', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
</div>
</section>
)}
@@ -390,12 +389,12 @@ export default function QuotePage() {
{/* 선택 항목 */}
{optionalItems.length > 0 && (
<section style={{ marginBottom: 32 }}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#a78bfa', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#a78bfa', display: 'inline-block' }} />
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#7c3aed', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7c3aed', display: 'inline-block' }} />
</h3>
<p style={{ color: '#475569', fontSize: 13, marginBottom: 12 }}> </p>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(167,139,250,0.2)', overflowX: 'auto' }}>
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 13, marginBottom: 12 }}> </p>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid rgba(29,78,216,0.2)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '6%' }} />
@@ -406,7 +405,7 @@ export default function QuotePage() {
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -419,19 +418,19 @@ export default function QuotePage() {
{optionalItems.map((item) => (
<tr key={item.id}
onClick={() => setCheckedOptional((prev) => ({ ...prev, [item.id]: !prev[item.id] }))}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(167,139,250,0.05)' : 'transparent', transition: 'background 0.2s' }}>
style={{ borderBottom: '1px solid var(--jsm-line)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(29,78,216,0.06)' : 'transparent', transition: 'background 0.2s' }}>
<td style={{ ...tdStyle, textAlign: 'center' }}>
<input type="checkbox" checked={!!checkedOptional[item.id]} onChange={() => {}} />
</td>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'white' : '#64748b' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#475569' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#64748b', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#a78bfa' : '#475569', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)' }}>{item.name}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-faint)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#7c3aed' : 'var(--jsm-ink-faint)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
{(item.unitPrice * item.quantity).toLocaleString()}
</td>
</tr>
@@ -444,24 +443,24 @@ export default function QuotePage() {
{/* 합계 */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, padding: '24px 28px', width: 360 }}>
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', borderRadius: 16, padding: '24px 28px', width: 360, boxShadow: '0 4px 16px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#64748b', fontSize: 14 }}> </span>
<span style={{ color: '#64748b', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}> </span>
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>40% </span>
<span style={{ color: '#34d399', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
<span style={{ color: '#059669', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
</div>
{optionalTotal > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ color: '#64748b', fontSize: 14 }}> </span>
<span style={{ color: '#a78bfa', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}> </span>
<span style={{ color: '#7c3aed', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}</span>
</div>
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ color: 'white', fontWeight: 700, fontSize: 16 }}> (VAT )</span>
<span style={{ color: 'white', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}> (VAT )</span>
<span style={{ color: 'var(--jsm-ink)', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
</div>
</div>
</div>
@@ -471,35 +470,36 @@ export default function QuotePage() {
{/* ── 향후 관리 ── */}
{(isPrinting || activeTab === 'maintenance') && quote.maintenance.length > 0 && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}> </h2>}
<p style={{ color: '#64748b', fontSize: 14, marginBottom: 20 }}> ( )</p>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}> </h2>}
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, marginBottom: 20 }}> ( )</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
{quote.maintenance.map((plan) => {
const isSelected = selectedMaintenance === plan.id;
return (
<div key={plan.id} onClick={() => setSelectedMaintenance(isSelected ? null : plan.id)}
style={{
background: isSelected ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.1))' : '#0f172a',
border: `1px solid ${isSelected ? '#6366f1' : 'rgba(255,255,255,0.06)'}`,
background: isSelected ? 'rgba(29,78,216,0.06)' : 'var(--jsm-surface)',
border: `1px solid ${isSelected ? 'var(--jsm-accent)' : 'var(--jsm-line)'}`,
borderRadius: 16, padding: 24, cursor: 'pointer', transition: 'all 0.25s', position: 'relative',
boxShadow: isSelected ? '0 4px 16px rgba(29,78,216,0.1)' : '0 2px 8px rgba(0,0,0,0.04)',
}}>
{plan.recommended && (
<div style={{ position: 'absolute', top: 16, right: 16, background: '#6366f1', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}></div>
<div style={{ position: 'absolute', top: 16, right: 16, background: 'var(--jsm-accent)', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}></div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<input type="radio" checked={isSelected} onChange={() => {}} />
<div>
<div style={{ color: 'white', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
<div style={{ color: '#475569', fontSize: 13 }}>{plan.period}</div>
<div style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 13 }}>{plan.period}</div>
</div>
</div>
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? '#818cf8' : 'white', marginBottom: 16, fontFamily: 'monospace' }}>
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? 'var(--jsm-accent)' : 'var(--jsm-ink)', marginBottom: 16, fontFamily: 'monospace' }}>
{plan.monthlyFee === 0 ? '무료' : plan.monthlyFee.toLocaleString() + '원/월'}
</div>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
{plan.includes.map((inc, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: '#94a3b8' }}>
<span style={{ color: '#6366f1', flexShrink: 0, marginTop: 1 }}></span>
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: 'var(--jsm-ink-soft)' }}>
<span style={{ color: 'var(--jsm-accent)', flexShrink: 0, marginTop: 1 }}></span>
{inc}
</div>
))}
@@ -513,32 +513,32 @@ export default function QuotePage() {
{/* 특이사항 */}
{quote.notes && (
<div style={{ marginTop: 40, background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', padding: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: '#475569', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}> </h3>
<p style={{ color: '#64748b', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
<div style={{ marginTop: 40, background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', padding: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--jsm-ink-soft)', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}> </h3>
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
</div>
)}
</div>
{/* 하단 고정 바 — 견적 수락 */}
{quote.status !== 'accepted' && quote.status !== 'rejected' && !isExpired && (
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(10,15,30,0.95)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(255,255,255,0.08)', padding: '16px 24px' }}>
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--jsm-navy)', borderTop: '1px solid rgba(255,255,255,0.1)', padding: '16px 24px' }}>
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ color: '#64748b', fontSize: 13 }}> </div>
<div style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}> </div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ color: 'white', fontSize: 24, fontWeight: 800, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
{maintenanceTotal > 0 && selectedPlan && (
<span style={{ color: '#6366f1', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}/ ({selectedPlan.name})</span>
<span style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}/ ({selectedPlan.name})</span>
)}
</div>
</div>
<button onClick={handleAccept} disabled={submitting}
style={{
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
background: 'var(--jsm-accent)',
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
boxShadow: '0 8px 32px rgba(99,102,241,0.4)',
boxShadow: '0 8px 32px rgba(29,78,216,0.4)',
opacity: submitting ? 0.7 : 1,
}}>
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
@@ -549,8 +549,8 @@ export default function QuotePage() {
{/* 수락된 경우 */}
{quote.status === 'accepted' && (
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(16,185,129,0.1)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(16,185,129,0.3)', padding: '16px 24px', textAlign: 'center' }}>
<p style={{ color: '#34d399', fontWeight: 600, fontSize: 16 }}> </p>
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(5,150,105,0.08)', borderTop: '1px solid rgba(5,150,105,0.3)', padding: '16px 24px', textAlign: 'center' }}>
<p style={{ color: '#059669', fontWeight: 600, fontSize: 16 }}> </p>
</div>
)}
@@ -562,13 +562,13 @@ export default function QuotePage() {
function StatCard({ label, value, sub, color }: { label: string; value: string; sub: string; color: string }) {
return (
<div style={{ background: '#0f172a', border: `1px solid ${color}20`, borderRadius: 16, padding: 24 }}>
<div style={{ color: '#475569', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
<div style={{ background: 'var(--jsm-surface)', border: `1px solid ${color}28`, borderRadius: 16, padding: 24, boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
<div style={{ color, fontSize: 28, fontWeight: 800, fontFamily: 'monospace', marginBottom: 4 }}>{value}</div>
<div style={{ color: '#374151', fontSize: 12 }}>{sub}</div>
<div style={{ color: 'var(--jsm-ink-faint)', fontSize: 12 }}>{sub}</div>
</div>
);
}
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.08em' };
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: '#94a3b8' };
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: 'var(--jsm-ink-soft)', textTransform: 'uppercase', letterSpacing: '0.08em' };
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: 'var(--jsm-ink-soft)' };

View File

@@ -6,9 +6,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: base, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
{ url: `${base}/packages`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${base}/services/music`, lastModified: now, changeFrequency: 'weekly', priority: 0.95 },
{ url: `${base}/saju`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
{ url: `${base}/outsourcing`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${base}/products`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/refund`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },

View File

@@ -1,4 +1,6 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'AI 사주 분석',
@@ -22,6 +24,7 @@ export const metadata: Metadata = {
},
};
export default function SajuLayout({ children }: { children: React.ReactNode }) {
return children;
export default async function SajuLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('saju'))) notFound();
return <>{children}</>;
}

View File

@@ -12,6 +12,31 @@
---
## 🔖 재개 체크포인트 (2026-06-08) — Phase 6 ③ DNS 전환 직전
**현재 상태:** Phase 0~5 ✅ 완료 + Phase 6 ①(DB 재구축·데이터 최종 동기) ✅ 완료. **③ DNS 전환만 남음(아직 운영=Vercel, 무영향).**
### 완료된 NAS 인프라
- **self-host Supabase**: `/volume1/docker/jsm`, PG **17.6**, 전 컨테이너 healthy. **데모 키 전량 교체 완료**(JWT_SECRET·ANON·SERVICE·VAULT·POSTGRES_PASSWORD 모두 실제값). KONG 8100/8543, pooler 5432 host 미노출(6543만).
- **supa 노출**: `https://supa.jaengseung-made.com` (Cloudflare DNS only → DSM 역프록시 443→kong:8100 + DSM Let's Encrypt). Google OAuth 활성(`GOTRUE_EXTERNAL_GOOGLE_*`, redirect=`https://supa.jaengseung-made.com/auth/v1/callback`, Google Console 등록됨).
- **데이터**: 클라우드→NAS 무손실(users4/profiles4/quotes4/orders15/payments4/products25/saju3/milestones7, RLS 정책 포함). storage 미사용(0).
- **앱 컨테이너**: 이미지 `gitea.gahusb.synology.me/gahusb/jsm-web:latest`(로컬 Docker Desktop 빌드→Gitea registry push→NAS pull). NAS `/volume1/docker/jsm-web/`(compose: image+env_file `.env`+`ports 13000:3000`+`extra_hosts host-gateway`). 컨테이너→supa **hairpin 200**(공유기 지원). 테스트 `https://app.jaengseung-made.com`(DSM 역프록시→13000) 브라우저 OAuth·데이터 검증 통과.
- **코드/배포**: Gitea `gitea.gahusb.synology.me/gahusb/jaengseung-made`(main=현재 + `backup-old-main`=옛). GitHub origin 동기. 빌드: `docker build --build-arg NEXT_PUBLIC_SUPABASE_URL=https://supa.jaengseung-made.com --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=<새 ANON> -t gitea.gahusb.synology.me/gahusb/jsm-web:latest .``docker push` → NAS `docker compose up -d`.
### ▶ 다음 재개 작업 (Phase 6 ②~⑤ → Phase 7)
1. **② DSM 역프록시** `jaengseung-made.com`·`www`(443) → `localhost:13000` 생성(인증서는 전환 후).
2. **③ Cloudflare DNS 전환**(=실제 컷오버): TTL 60s로 낮춘 뒤 `jaengseung-made.com` A `216.198.79.1`→**`211.44.164.244`**, `www`도 NAS로. `ADDITIONAL_REDIRECT_URLS``https://jaengseung-made.com/**` 포함 확인(설정됨).
3. **④ 인증서**: 전환 직후 DSM Let's Encrypt `jaengseung-made.com`·`www` 발급(80 도달) → 역프록시 매핑 → 전 기능 검증(로그인·mypage·결제·사주·문의).
4. **⑤ 롤백**: 문제 시 Cloudflare 레코드 `216.198.79.1`(Vercel)로 복구.
5. **Phase 7**: Postgres 백업 cron + 모니터링(텔레그램 재활용) + 인증서 자동갱신 확인 → 안정 1~2주 후 Vercel·클라우드 Supabase 해지(백업 2중 확인). 노출된 클라우드 비번은 해지로 자연 폐기.
### 주의
- 클라우드 Supabase는 **멀티앱 공유**(ebay/lotto/wk 별개) → 클라우드 해지는 그 앱들 정리 후. 쟁승은 NAS 독립.
- 로컬 `.env.local`은 클라우드값(백업 `.env.local.cloud.bak`). 로컬 supa 재테스트 시 supa/새키로 교체 후 복구.
- Vercel/클라우드는 컷오버 후에도 **해지 전까지 유지**(롤백 안전망).
---
## 변수 (실행자가 본인 환경값으로 채움)
| 변수 | 의미 | 예시 |

View File

@@ -0,0 +1,446 @@
# 사이트 리뉴얼 Phase 1 — 디자인 시스템 + IA 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **UI 페이지 태스크(5~8)는 구현 시 반드시 `designer` + `soft-skill` 스킬을 먼저 로드**하여 generic AI 패턴(과한 그래디언트·글래스모피즘·보라색 남용)을 차단할 것.
**Goal:** 쟁승메이드를 "외주 개발 + 완성 소프트웨어 판매" 2축의 전문 B2B 에이전시 사이트로 풀 리디자인하고, 기존 서비스(사주·음악·로또·설문)를 admin 전용 숨김 처리한다.
**Architecture:** 기존 다크 글래스 `--kx-*` 토큰 체계를 라이트 slate+딥블루 전문 토큰으로 전면 교체. Jua → Pretendard. 숨김은 `service_settings` 토글 + 서버 레이아웃 가드(admin_token 쿠키 예외). 페이지는 기존 라우트 구조 위에서 교체하며 web-backend·결제 로직은 건드리지 않는다.
**Tech Stack:** Next.js 16 App Router, TypeScript, Tailwind CSS v4, Supabase (Auth/DB), Pretendard(npm)
**Spec:** `docs/superpowers/specs/2026-06-11-site-renewal-outsourcing-products-design.md`
**검증 방식 노트:** 이 저장소에 테스트 인프라가 없고 Phase 1은 UI·라우팅 중심이므로 TDD 대신 `npm run build` + dev 서버 수동 E2E 체크리스트(Task 10)로 검증한다. 로직 단위 테스트는 Phase 2(주문/다운로드 검증 로직)에서 vitest 도입과 함께 시작.
---
## 사전 확인 사항 (현재 코드 상태)
- 레이아웃: `app/layout.tsx` (Jua 폰트, 음악 팩 중심 metadata/jsonLd) → 교체 대상
- 셸: `app/components/DashboardShell.tsx` (standalone 분기) → 유지, `app/components/PublicShell.tsx`(TopNav+푸터) → 리뉴얼
- 네비: `app/components/TopNav.tsx` — 링크가 SaaS제품/AI음악/커스텀외주 → 교체
- 토큰: `app/globals.css` `--kx-*` 다크 테마 (#060e20 배경, 보라 #cc97ff 프라이머리) → 교체
- 숨김 토글 인프라: `service_settings` 테이블 + `app/api/admin/services/route.ts`(GET/PATCH, DEFAULT_SERVICES) + `app/admin/services/page.tsx` 존재 → 재사용
- admin 인증: `lib/admin-auth.ts``verifyAdminTokenNode(token)` + `admin_token` 쿠키
- 리다이렉트: `next.config.ts` redirects() 존재 → 추가만
---
### Task 1: 디자인 토큰 + Pretendard 폰트 교체
**Files:**
- Modify: `package.json` (pretendard 추가)
- Modify: `app/globals.css` (토큰 교체)
- Modify: `app/layout.tsx` (Jua 제거, Pretendard 적용)
- [ ] **Step 1: pretendard 설치**
```bash
npm install pretendard
```
- [ ] **Step 2: `app/globals.css` 토큰 교체**
기존 `--kx-*` 변수 블록(40~51행 부근)을 **유지한 채 아래 신규 토큰을 추가**하고, 기존 kx 변수의 값만 새 라이트 테마로 재매핑한다 (kx 변수를 참조하는 기존 페이지가 많아 즉시 삭제하면 깨짐 — Phase 1 완료 후 잔여 참조 제거):
```css
:root {
/* === JSM Professional tokens (2026-06 renewal) === */
--jsm-bg: #f8fafc; /* slate-50 본문 배경 */
--jsm-surface: #ffffff; /* 카드 */
--jsm-surface-alt: #f1f5f9; /* slate-100 섹션 교차 배경 */
--jsm-ink: #0f172a; /* slate-900 본문 텍스트 */
--jsm-ink-soft: #475569; /* slate-600 보조 텍스트 */
--jsm-ink-faint: #94a3b8; /* slate-400 캡션 */
--jsm-line: #e2e8f0; /* slate-200 보더 */
--jsm-navy: #0b1f3a; /* 딥네이비 — 푸터/다크 섹션 */
--jsm-accent: #1d4ed8; /* blue-700 포인트 (단일 포인트 컬러) */
--jsm-accent-hover: #1e40af; /* blue-800 */
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
/* 기존 kx 변수 재매핑 (잔여 참조 호환용) */
--kx-surface: var(--jsm-bg);
--kx-surface-low: var(--jsm-surface-alt);
--kx-surface-mid: var(--jsm-surface);
--kx-surface-high: var(--jsm-surface);
--kx-surface-bright: var(--jsm-surface-alt);
--kx-on-surface: var(--jsm-ink);
--kx-on-variant: var(--jsm-ink-soft);
--kx-primary: var(--jsm-accent);
--kx-primary-dim: var(--jsm-accent-hover);
--kx-secondary: var(--jsm-accent);
--kx-secondary-dim: var(--jsm-accent-hover);
--kx-outline: var(--jsm-line);
}
```
`body` 폰트 스택을 `var(--font-pretendard), Pretendard Variable, Pretendard, -apple-system, sans-serif`로 변경. `.kx-display`, `.kx-gradient-text`, `.kx-btn-primary` 등 기존 유틸 클래스는 새 토큰 기반으로 값만 정제 (gradient-text는 단색 `--jsm-ink`로, btn-primary는 `--jsm-accent` 솔리드로).
- [ ] **Step 3: `app/layout.tsx`에서 Jua 제거 + Pretendard import**
```tsx
// 제거: import { Jua } from "next/font/google"; 및 jua 정의/사용
import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
// <html className={jua.variable}> → <html> (또는 className 제거)
```
- [ ] **Step 4: 빌드 확인**
Run: `npm run build`
Expected: 성공 (스타일 회귀는 Task 4~8에서 페이지별 정리)
- [ ] **Step 5: Commit**
```bash
git add package.json package-lock.json app/globals.css app/layout.tsx
git commit -m "feat(design): JSM 전문 토큰 체계 + Pretendard 도입, kx 토큰 재매핑"
```
---
### Task 2: 서비스 숨김 가드 라이브러리
**Files:**
- Create: `lib/service-visibility.ts`
- Create: `supabase/migrations/2026-06-11-hide-legacy-services.sql`
- Modify: `app/api/admin/services/route.ts` (DEFAULT_SERVICES 갱신)
- [ ] **Step 1: 마이그레이션 SQL 작성** (클라우드 + self-host 양쪽 적용 — 멱등)
```sql
-- 2026-06-11 리뉴얼: 레거시 서비스 숨김 토글 시드
-- service_settings: 신규 id 추가 (이미 있으면 무시)
INSERT INTO service_settings (id, name, description, is_active, order_index)
VALUES
('saju', 'AI 사주 분석', '사주 입력 및 AI 해석 (레거시)', false, 101),
('music', 'AI 음악 팩', '음악 가이드 패키지·샘플·스튜디오', false, 102),
('gyeol', 'CONTOUR 설문', '/gyeol PMF 설문', false, 103),
('packages', 'SaaS 제품 허브(구)', '구 /packages 페이지', false, 104),
('lotto', '로또 추천', '로또 번호 추천 노출', false, 105)
ON CONFLICT (id) DO NOTHING;
```
- [ ] **Step 2: `lib/service-visibility.ts` 작성**
```typescript
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto';
/**
* 서비스 노출 여부. admin_token 세션이면 항상 true.
* service_settings 조회 실패(테이블 미생성 등) 시 안전하게 숨김(false).
*/
export async function isServiceVisible(id: HideableService): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (token && verifyAdminTokenNode(token)) return true;
try {
const supabase = createAdminClient();
const { data, error } = await supabase
.from('service_settings')
.select('is_active')
.eq('id', id)
.maybeSingle();
if (error || !data) return false;
return data.is_active === true;
} catch {
return false;
}
}
```
- [ ] **Step 3: `app/api/admin/services/route.ts`의 `DEFAULT_SERVICES`를 신규 id 체계로 갱신**
```typescript
const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
];
```
- [ ] **Step 4: 마이그레이션 적용 (운영 DB 2곳)**
Run (CEO 또는 세션에서): 클라우드 Supabase SQL Editor + NAS self-host(`supa.jaengseung-made.com`) SQL Editor 양쪽에 Step 1 SQL 실행.
Expected: 5 rows inserted (또는 0 — 재실행 시).
- [ ] **Step 5: Commit**
```bash
git add lib/service-visibility.ts supabase/migrations/2026-06-11-hide-legacy-services.sql app/api/admin/services/route.ts
git commit -m "feat(visibility): service_settings 기반 서비스 숨김 가드 + 레거시 서비스 시드"
```
---
### Task 3: 레거시 라우트에 숨김 가드 적용
**Files:**
- Create: `app/work/saju/layout.tsx`
- Create: `app/music/layout.tsx`
- Create: `app/gyeol/layout.tsx` (기존에 있으면 Modify — 가드만 추가)
- Create: `app/packages/layout.tsx`
- [ ] **Step 1: 서버 레이아웃 가드 4개 작성** (모두 동일 패턴, id만 다름)
`app/work/saju/layout.tsx`:
```tsx
import { notFound } from 'next/navigation';
import { isServiceVisible } from '@/lib/service-visibility';
export default async function SajuLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('saju'))) notFound();
return <>{children}</>;
}
```
`app/music/layout.tsx``isServiceVisible('music')`,
`app/gyeol/layout.tsx``isServiceVisible('gyeol')` (기존 layout 있으면 함수 본문에 가드 추가),
`app/packages/layout.tsx``isServiceVisible('packages')`.
주의: 해당 디렉토리에 기존 `layout.tsx`가 이미 있으면 새로 만들지 말고 기존 파일 상단에 가드 삽입. 클라이언트 레이아웃이면 서버 래퍼 레이아웃을 한 단계 위에 둘 수 없으므로 page 단위로 가드 서버 컴포넌트 래핑.
- [ ] **Step 2: dev 서버에서 동작 확인**
Run: `npm run dev`
- 비로그인 브라우저(시크릿): `/work/saju`, `/music/packs`, `/gyeol`, `/packages` → 404
- `/admin/login` 로그인 후 동일 URL → 정상 렌더
Expected: 위와 같음
- [ ] **Step 3: Commit**
```bash
git add app/work/saju/layout.tsx app/music/layout.tsx app/gyeol/layout.tsx app/packages/layout.tsx
git commit -m "feat(visibility): 사주·음악·설문·패키지 라우트 숨김 가드 적용"
```
---
### Task 4: TopNav + 푸터(PublicShell) 리뉴얼
**Files:**
- Modify: `app/components/TopNav.tsx`
- Modify: `app/components/PublicShell.tsx`
- [ ] **Step 1: TopNav 링크·CTA 교체**
```typescript
const LINKS = [
{ href: '/outsourcing', label: '외주 개발' },
{ href: '/products', label: '소프트웨어' },
];
// CTA 버튼: "Try now"(→/music) 제거 → "프로젝트 문의" (→/outsourcing#contact)
```
스타일: 라이트 배경 기준으로 재작성 — 스크롤 시 흰 배경 + `--jsm-line` 하단 보더 + 약한 그림자(현재의 다크 글래스 blur 제거). 로고 "JSM"은 gradient-text 제거, `--jsm-ink` 단색 + "쟁승메이드" 병기. 로그인/마이페이지/로그아웃 분기 로직은 그대로 유지(Supabase auth 구독 코드 무수정).
- [ ] **Step 2: PublicShell 푸터 링크 그룹 교체**
```
서비스: 외주 개발(/outsourcing) · 소프트웨어(/products)
회사: 문의하기(mailto) · 진행 프로세스(/outsourcing#process)
Legal: 이용약관 · 개인정보처리방침 · 환불 정책 (유지)
```
숨김 서비스 링크(SaaS 제품/AI 음악/AI 사주) 전부 제거. 푸터 배경은 `--jsm-navy`, 사업자 정보 라인 유지. `KakaoFloatButton` 유지.
- [ ] **Step 3: dev 서버에서 네비·푸터 렌더 확인** (모바일 드로어 포함)
- [ ] **Step 4: Commit**
```bash
git add app/components/TopNav.tsx app/components/PublicShell.tsx
git commit -m "feat(nav): 외주·소프트웨어 2축 네비게이션 + 푸터 리뉴얼"
```
---
### Task 5: 메인 페이지 리디자인 (`app/page.tsx`) + 메타데이터
**Files:**
- Modify: `app/page.tsx` (전면 교체)
- Modify: `app/layout.tsx` (metadata + jsonLd 교체)
> **구현 전 designer + soft-skill 스킬 로드 필수.**
- [ ] **Step 1: `app/layout.tsx` metadata/jsonLd 교체**
- title default: `"외주 개발 · 완성 소프트웨어 | 쟁승메이드"`
- description: `"7년차 대기업 백엔드 개발자가 직접 설계하고 만듭니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드."`
- keywords: 외주 개발, 소프트웨어 개발, 웹사이트 제작, 업무 자동화, 백엔드 개발자, 프리랜서 개발자
- jsonLd: OfferCatalog에서 음악 팩·사주 Offer 제거, 외주 개발·웹사이트 제작 Service만 유지 (소프트웨어 제품 Offer는 Phase 2에서 동적 생성)
- [ ] **Step 2: 메인 페이지 섹션 구성으로 전면 재작성** (서버 컴포넌트, 이미지 없이 타이포·여백·SVG)
| 섹션 | 내용 (실제 카피) |
|------|------|
| Hero | 헤드라인: "필요한 소프트웨어, 만들어 드리거나 — 이미 만들어 두었습니다." 서브: "7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과 검증된 완성 소프트웨어 중 선택하세요." CTA 2개: [프로젝트 문의하기 → /outsourcing#contact] [소프트웨어 보기 → /products] |
| 2축 서비스 | 카드 2장 — ① 외주 개발: "기획부터 배포·운영까지. 웹/API/자동화/봇" → /outsourcing ② 완성 소프트웨어: "결제 후 바로 다운로드. 직접 운영하며 검증한 도구" → /products |
| 개발 프로세스 | 4단계 타임라인: 01 무료 상담·요구 정리 → 02 견적·범위 확정 → 03 개발·중간 공유 → 04 납품·배포 지원 (각 1줄 설명) |
| 신뢰 요소 | 숫자 스탯: "7년차 대기업 백엔드" · "직접 운영 중인 서비스 15+" · "기획→배포 원스톱" + 기술 스택 라벨(Python·Java·Spring·Next.js·AI 연동) |
| 포트폴리오 하이라이트 | 실제 운영 사례 3장: 주식 자동매매 시스템(텔레그램 연동) / 부동산 청약 자동 수집·매칭 / AI 콘텐츠 자동화 파이프라인 — 각 "직접 개발·운영 중" 뱃지, [더 보기 → /outsourcing#portfolio] |
| 소프트웨어 진열 | Phase 1은 정적: "출시 준비 중인 제품" 카드 + [입고 알림 → /outsourcing#contact]. Phase 2에서 products 테이블 동적 진열로 교체 예정 주석 명시 |
| 최종 CTA | 네이비 풀폭 밴드: "프로젝트, 이야기부터 시작하세요" + [무료 상담 신청] |
- [ ] **Step 3: 빌드 + 렌더 확인**
Run: `npm run build && npm run dev``/` 데스크톱·모바일 확인
- [ ] **Step 4: Commit**
```bash
git add app/page.tsx app/layout.tsx
git commit -m "feat(home): 외주+소프트웨어 2축 메인 페이지 풀 리디자인"
```
---
### Task 6: `/outsourcing` 페이지 신설 + 리다이렉트
**Files:**
- Create: `app/outsourcing/page.tsx`
- Modify: `next.config.ts` (redirects 추가)
- 참고(복사 소스): `app/work/freelance/page.tsx`, `app/components/ContactForm.tsx`
> **구현 전 designer + soft-skill 스킬 로드 필수.** 단계형 폼은 Phase 3 — 이번엔 기존 `ContactForm` 재사용.
- [ ] **Step 1: `app/outsourcing/page.tsx` 작성** — 섹션 구성:
1. Hero: "맞춤 소프트웨어 외주 개발" + "기획 정리가 안 됐어도 괜찮습니다. 상담에서 함께 정리합니다."
2. 제공 분야 카드 6: 웹 서비스 / 웹사이트 제작 / 업무 자동화(RPA·엑셀·크롤링) / API·백엔드 / 텔레그램·디스코드 봇 / AI 연동 개발
3. `#process` 진행 프로세스: 상담 → 견적(영업일 2일 내) → 계약·착수 → 중간 공유(주 1회 이상) → 납품·검수 → 무상 하자보수 30일
4. `#portfolio` 포트폴리오: 기존 `/work/freelance`의 실사례 + `/work/website/samples/*` 샘플 링크 카드 재사용
5. 자주 묻는 질문 4개: 견적 기준 / 수정 횟수 / 소스코드 제공 / 유지보수
6. `#contact` 의뢰 폼: 기존 `ContactForm` 컴포넌트 그대로 임베드 (새 토큰으로 스타일만 정제)
- [ ] **Step 2: `next.config.ts` redirects 추가**
```typescript
{ source: '/work/freelance', destination: '/outsourcing', permanent: true },
{ source: '/work', destination: '/outsourcing', permanent: true },
{ source: '/work/website', destination: '/outsourcing', permanent: true },
// 주의: /work/saju*, /work/website/samples/* 는 redirect 하지 않음 (admin 접근용 잔존)
```
기존 redirects 중 destination이 `/work`·`/work/freelance`·`/work/website`인 항목은 새 destination(`/outsourcing`)으로 갱신해 redirect 체인 방지.
- [ ] **Step 3: dev 확인**`/outsourcing` 렌더 + `/work/freelance``/outsourcing` 301 + 폼 제출 1회 테스트(접수 메일 수신)
- [ ] **Step 4: Commit**
```bash
git add app/outsourcing/page.tsx next.config.ts
git commit -m "feat(outsourcing): 외주 의뢰 페이지 신설 + work 라우트 리다이렉트"
```
---### Task 7: `/login` 리디자인
**Files:**
- Modify: `app/login/page.tsx`
- [ ] **Step 1: 비주얼 리디자인** — Supabase 로직(signUp/signInWithPassword/signInWithOAuth, 에러 처리, redirect) **무수정**, 마크업·스타일만 교체:
- 중앙 카드형 (max-w-sm, 흰 카드, `--jsm-line` 보더), 좌측 또는 상단에 "쟁승메이드" 워드마크
- Google 버튼 → 이메일 폼 순서, 가입/로그인 토글 유지
- 다크 배경·글래스 효과 제거, `--jsm-bg` 배경
- [ ] **Step 2: dev 확인** — 이메일 로그인 + Google OAuth 각 1회
- [ ] **Step 3: Commit**
```bash
git add app/login/page.tsx
git commit -m "feat(login): 로그인 페이지 전문 톤 리디자인 (로직 무수정)"
```
---
### Task 8: `/mypage` 리디자인 + 탭 재구성
**Files:**
- Modify: `app/mypage/page.tsx` (1,048줄 — 탭 단위로 작업)
- [ ] **Step 1: 탭 구조 변경** — 기존 7탭 → 4탭:
| 새 탭 | 기존 소스 | 처리 |
|------|----------|------|
| 프로필 | 프로필 탭 | 유지·리스타일 |
| 내 의뢰 | 의뢰 탭 | 유지·리스타일 (Phase 3에서 타임라인 고도화) |
| 내 제품 | 팩 탭 | 명칭 변경·리스타일 — **다운로드 로직(sign-link 호출) 무수정** |
| 주문 내역 | 결제+주문 탭 통합 | 병합·리스타일 |
사주·구독 탭: 렌더 분기에서 제외(코드는 주석이 아닌 조건 `false` 처리 또는 제거 — 데이터 로직은 남겨도 무방). 탭 상태/데이터 페칭 구조는 유지.
- [ ] **Step 2: 비주얼 리디자인** — 새 토큰 적용, 카드·테이블 정제. 다운로드 버튼/상태 뱃지 명확화("입금 확인 후 활성화됩니다" 안내 문구 포함).
- [ ] **Step 3: dev 확인** — 로그인 → 4탭 전환 → 기존 구매 계정으로 다운로드 버튼 동작(또는 테스트 데이터) 확인
- [ ] **Step 4: Commit**
```bash
git add app/mypage/page.tsx
git commit -m "feat(mypage): 4탭 재구성 + 전문 톤 리디자인 (다운로드 로직 무수정)"
```
---
### Task 9: 잔여 kx 참조·레거시 노출 정리
**Files:**
- Modify: `app/payment/success/page.tsx`, `app/payment/fail/page.tsx`, `app/quote/[token]/page.tsx`, `app/legal/*/page.tsx` — 새 토큰 기준 톤 정제 (구조 변경 없음)
- Modify: `app/components/KakaoFloatButton.tsx` 등 공용 컴포넌트의 다크 전제 스타일
- [ ] **Step 1: 잔여 다크 전제 스타일 검색·정리**
Run: `grep -rn "kx-gradient-text\|GlassFilter\|LiquidGlass" app/ --include="*.tsx"`
→ 노출 페이지(숨김 라우트 제외)에서 발견된 사용처를 새 토큰으로 정리. `app/layout.tsx``<GlassFilter />` 제거.
- [ ] **Step 2: 빌드 확인**`npm run build` 성공
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "refactor(design): 잔여 글래스·그래디언트 스타일 정리"
```
---
### Task 10: Phase 1 E2E 검증 (스펙 §8 시나리오 C + 회귀)
- [ ] **Step 1: 숨김 검증 (시나리오 C)**
- 시크릿 창: `/work/saju`, `/music/packs`, `/music/studio`, `/gyeol`, `/packages` → 전부 404
- admin 로그인 창: 동일 URL 전부 정상 렌더
- `/admin/services`에서 'music' 토글 ON → 시크릿 창 `/music/packs` 정상 렌더 → 다시 OFF → 404
- [ ] **Step 2: 핵심 동선 회귀**
- `/` → 네비·Hero CTA → `/outsourcing` → 폼 제출 → 접수 메일 수신 + `/admin/contacts`에 기록
- 회원 로그인 → `/mypage` 4탭 정상 + (구매 데이터 있으면) 다운로드 링크 발급
- `/work/freelance``/outsourcing` 301 확인
- 모바일 뷰(375px): 네비 드로어·메인·외주 페이지 레이아웃 확인
- [ ] **Step 3: 빌드·배포 준비**
Run: `npm run build`
Expected: 에러 0. (배포는 CEO 확인 후 git push → Vercel)
- [ ] **Step 4: 최종 Commit + Phase 1 완료 보고**
```bash
git add -A && git commit -m "chore: Phase 1 디자인+IA 리뉴얼 완료"
```
---
## Phase 2·3 예고 (별도 플랜으로 작성)
- **Phase 2 — 제품 판매 시스템**: DB 마이그레이션(products 확장·pack_files.product_id·orders 정비), `/products` 카탈로그·상세, 계좌이체 구매 모달 일반화, `/admin/orders`·`/admin/products`, sign-link 검증 orders 기반 교체, 기존 구매자 이관. vitest 도입.
- **Phase 3 — 외주 고객 포털**: 단계형 의뢰 폼, contact↔quote FK, 상태 머신, `/track/[token]`, 자동 이메일(Resend), admin 의뢰·견적 통합 뷰.

View File

@@ -0,0 +1,750 @@
# 사이트 리뉴얼 Phase 2 — 제품 판매 시스템 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **UI 태스크(7·9·10)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.** 토큰은 `--jsm-*`만, gradient/blur/보라/이모지 금지.
**Goal:** 완성 소프트웨어 판매를 "orders 테이블 단일 소스"로 재구축 — 동적 카탈로그(/products), 계좌이체 주문, admin 입금 확인, orders 기반 다운로드 검증, 기존 음악 팩 구매자 이관.
**Architecture:** `products` 테이블 확장(is_listed/features/description_long/pay_method/sort_order) + `pack_files.product_id` 연결로 pack 인프라를 범용화. 구매 식별은 `contact_requests.service` 문자열 파싱(`extractPackTier`)을 폐기하고 `orders(status='paid')` 기반 `lib/product-access.ts`로 교체. web-backend(packs-lab)는 무수정 — 업로드는 기존 tier 토큰 방식 유지 후 프론트에서 product_id 배정.
**Tech Stack:** Next.js 16 App Router, Supabase, Resend, vitest(신규)
**Spec:** `docs/superpowers/specs/2026-06-11-site-renewal-outsourcing-products-design.md` §1·3·4·6
**Branch:** `feature/renewal-phase2` (Phase 1 머지된 main 기준)
---
## ⚠️ 배포 순서 제약 (치명적)
Task 6(sign-link 교체) 이후의 코드는 **orders에 이관 데이터가 있어야** 기존 음악 팩 구매자의 다운로드가 유지된다.
**머지·배포 전에 Task 2·3의 SQL을 클라우드 Supabase + NAS self-host 양쪽에 반드시 먼저 적용**할 것. (미적용 상태로 배포하면 기존 구매자 다운로드 끊김)
## 현재 코드 기준점 (탐색 검증됨)
- `products`: id text PK, name, description, price, category, is_active, created_at (`supabase/schema.sql:56-72`). **음악 팩 제품 행 없음** — contact_requests 문자열로만 식별 중
- `orders`: id uuid, user_id, product_id→products, amount, status('pending'|'paid'|'failed'|'cancelled'), metadata jsonb (`schema.sql:75-89`)
- `pack_files`: id uuid, min_tier check('starter'|'pro'|'master'), label, file_path unique, filename, size_bytes, sort_order, uploaded_at, deleted_at
- 구매 문자열 포맷: `"구매 신청: AI 음악 생성 개발 가이드 · 프로"` (U+00B7) — 생성 `PurchaseAgreementModal.tsx:56`, 파싱 `lib/pack-assets.ts:33-39 extractPackTier`
- 다운로드 체인: `/api/packs/list-mine`·`/api/packs/sign-link` → contact_requests(completed) → extractPackTier → tierIncludes → `lib/supabase/pack-files.ts getPackFilesForTiers``lib/web-backend.ts signLink`(HMAC)
- 업로드: `/api/admin/packs/upload-url`(tier/label/filename/sizeBytes → mintUploadToken) → 브라우저 XHR로 web-backend 직접 업로드 → **web-backend가 pack_files 행 생성(min_tier 포함)**
- admin: `AdminSidebar.tsx NAV_ITEMS`에 메뉴 등록, `admin_token` 쿠키 + `verifyAdminTokenNode` 인증 패턴 (`app/api/admin/services/route.ts:8-12 checkAuth`)
- 메일: Resend, from `쟁승메이드 <noreply@jaengseung-made.com>`, CEO 수신 `bgg8988@gmail.com` (`app/api/contact/route.ts:74-100`)
- 테스트 인프라 없음 → Task 1에서 vitest 도입
---
### Task 1: vitest 도입 + `lib/product-access.ts` (TDD)
**Files:**
- Modify: `package.json` (vitest + script)
- Create: `vitest.config.ts`
- Create: `lib/product-access.ts`
- Test: `lib/__tests__/product-access.test.ts`
- [ ] **Step 1: vitest 설치 + 설정**
```bash
npm install -D vitest
```
`vitest.config.ts`:
```typescript
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: { include: ['lib/**/*.test.ts'] },
resolve: { alias: { '@': path.resolve(__dirname, '.') } },
});
```
`package.json` scripts에 `"test": "vitest run"` 추가.
- [ ] **Step 2: 실패하는 테스트 작성**`lib/__tests__/product-access.test.ts`
```typescript
import { describe, it, expect } from 'vitest';
import { expandProductAccess, MUSIC_PRODUCT_CHAIN } from '@/lib/product-access';
describe('expandProductAccess', () => {
it('일반 제품은 자기 자신만 반환', () => {
expect(expandProductAccess(['lotto_tool'])).toEqual(['lotto_tool']);
});
it('music_pro는 starter를 포함', () => {
expect(expandProductAccess(['music_pro']).sort()).toEqual(['music_pro', 'music_starter'].sort());
});
it('music_master는 전 tier 포함', () => {
expect(expandProductAccess(['music_master']).sort()).toEqual(
['music_master', 'music_pro', 'music_starter'].sort(),
);
});
it('중복 입력은 중복 없이 반환', () => {
expect(expandProductAccess(['music_pro', 'music_starter']).sort()).toEqual(
['music_pro', 'music_starter'].sort(),
);
});
it('빈 입력은 빈 배열', () => {
expect(expandProductAccess([])).toEqual([]);
});
});
```
- [ ] **Step 3: 실패 확인** — Run: `npm test` / Expected: FAIL (module not found)
- [ ] **Step 4: 구현**`lib/product-access.ts`
```typescript
/**
* orders 기반 제품 접근 확장.
* 음악 팩 상위 tier는 하위 tier 파일도 포함(하위 호환) — 신규 제품은 1:1.
*/
export const MUSIC_PRODUCT_CHAIN: Record<string, string[]> = {
music_starter: ['music_starter'],
music_pro: ['music_pro', 'music_starter'],
music_master: ['music_master', 'music_pro', 'music_starter'],
};
export function expandProductAccess(paidProductIds: string[]): string[] {
const out = new Set<string>();
for (const id of paidProductIds) {
for (const expanded of MUSIC_PRODUCT_CHAIN[id] ?? [id]) out.add(expanded);
}
return Array.from(out);
}
```
- [ ] **Step 5: 통과 확인** — Run: `npm test` / Expected: 5 passed
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json vitest.config.ts lib/product-access.ts lib/__tests__/product-access.test.ts
git commit -m "feat(products): vitest 도입 + 제품 접근 확장 로직 (music tier 하위 호환)"
```
---
### Task 2: DB 마이그레이션 ① — products 확장 + 음악 제품 시드 + pack_files.product_id
**Files:**
- Create: `supabase/migrations/2026-06-12-products-extend.sql`
- [ ] **Step 1: SQL 작성** (멱등 — 재실행 안전)
```sql
-- 2026-06-12 Phase 2: products 범용 제품 시스템 확장
-- (1) products 컬럼 확장
ALTER TABLE products ADD COLUMN IF NOT EXISTS description_long text;
ALTER TABLE products ADD COLUMN IF NOT EXISTS features jsonb; -- string[] 형태
ALTER TABLE products ADD COLUMN IF NOT EXISTS pay_method text NOT NULL DEFAULT 'bank_transfer';
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_listed boolean NOT NULL DEFAULT false; -- /products 카탈로그 노출
ALTER TABLE products ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
-- (2) 음악 팩 제품 시드 (다운로드 권한 매핑용 — 카탈로그 비노출 is_listed=false)
INSERT INTO products (id, name, description, price, category, is_active, is_listed)
VALUES
('music_starter', 'AI 음악 생성 개발 가이드 (입문)', '음악 팩 입문 — 레거시', 39000, 'software', true, false),
('music_pro', 'AI 음악 생성 개발 가이드 (프로)', '음악 팩 프로 — 레거시', 99000, 'software', true, false),
('music_master', 'AI 음악 생성 개발 가이드 (마스터)', '음악 팩 마스터 — 레거시', 149000, 'software', true, false)
ON CONFLICT (id) DO NOTHING;
-- (3) pack_files → 제품 연결
ALTER TABLE pack_files ADD COLUMN IF NOT EXISTS product_id text REFERENCES products(id);
UPDATE pack_files SET product_id = 'music_' || min_tier WHERE product_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_pack_files_product ON pack_files (product_id) WHERE deleted_at IS NULL;
```
- [ ] **Step 2: 로컬 검증** — SQL 문법 눈검토 + `supabase/schema.sql`과 컬럼명 대조 (pack_files는 `2026-05-02-create-pack-files.sql` 기준)
- [ ] **Step 3: Commit**
```bash
git add supabase/migrations/2026-06-12-products-extend.sql
git commit -m "feat(db): products 카탈로그 확장 + 음악 제품 시드 + pack_files.product_id 백필"
```
---
### Task 3: DB 마이그레이션 ② — 기존 구매자 orders 이관
**Files:**
- Create: `supabase/migrations/2026-06-12-migrate-pack-purchases.sql`
- [ ] **Step 1: SQL 작성** (멱등 — `metadata->>'source_contact_id'` 마커로 중복 방지)
```sql
-- 2026-06-12 Phase 2: 음악 팩 구매(contact_requests 문자열) → orders 이관
-- 대상: status='completed' AND user_id 보유 AND '구매 신청:' + '· 입문/프로/마스터' 패턴
-- (user_id 없는 행은 현행 다운로드 시스템도 서빙하지 않으므로 이관 대상 아님)
INSERT INTO orders (user_id, product_id, amount, status, metadata, created_at)
SELECT
cr.user_id,
p.id,
p.price,
'paid',
jsonb_build_object(
'method', 'bank_transfer',
'source_contact_id', cr.id::text,
'migrated_at', now()::text,
'original_service', cr.service
),
cr.created_at
FROM contact_requests cr
JOIN products p ON p.id = CASE
WHEN cr.service LIKE '%· 입문' THEN 'music_starter'
WHEN cr.service LIKE '%· 프로' THEN 'music_pro'
WHEN cr.service LIKE '%· 마스터' THEN 'music_master'
END
WHERE cr.status = 'completed'
AND cr.user_id IS NOT NULL
AND cr.service LIKE '구매 신청:%'
AND NOT EXISTS (
SELECT 1 FROM orders o WHERE o.metadata->>'source_contact_id' = cr.id::text
);
```
주의: `· 입문` 등의 가운뎃점은 **U+00B7** (`lib/pack-assets.ts`와 동일 문자) — 파일 인코딩 UTF-8 확인.
- [ ] **Step 2: 검증 쿼리를 파일 하단에 주석으로 포함**
```sql
-- 검증: 이관 건수 = 대상 건수 확인
-- SELECT count(*) FROM orders WHERE metadata ? 'source_contact_id';
-- SELECT count(*) FROM contact_requests WHERE status='completed' AND user_id IS NOT NULL AND service LIKE '구매 신청:%' AND (service LIKE '%· 입문' OR service LIKE '%· 프로' OR service LIKE '%· 마스터');
```
- [ ] **Step 3: Commit**
```bash
git add supabase/migrations/2026-06-12-migrate-pack-purchases.sql
git commit -m "feat(db): 음악 팩 구매 이력 contact_requests → orders 멱등 이관"
```
- [ ] **Step 4 (운영 — 컨트롤러/CEO):** Task 2·3 SQL을 **클라우드 + self-host 양쪽** SQL Editor에서 순서대로 실행하고 검증 쿼리 결과 일치 확인. **Task 6 배포 전 필수.**
---
### Task 4: `lib/supabase/product-files.ts` — orders 기반 조회 헬퍼 (TDD 일부)
**Files:**
- Create: `lib/supabase/product-files.ts`
- Test: `lib/__tests__/product-files.test.ts` (순수 로직만 — Supabase 호출부는 통합 검증)
- [ ] **Step 1: 구현**
```typescript
import type { SupabaseClient } from '@supabase/supabase-js';
import { expandProductAccess } from '@/lib/product-access';
export interface ProductRow {
id: string;
name: string;
description: string | null;
description_long: string | null;
price: number;
category: string;
is_active: boolean;
is_listed: boolean;
sort_order: number;
features: string[] | null;
pay_method: string;
}
export interface ProductFile {
id: string;
product_id: string | null;
label: string;
file_path: string;
filename: string;
size_bytes: number;
sort_order: number;
uploaded_at: string;
deleted_at: string | null;
min_tier: string; // 레거시 컬럼 — 신규 로직에서는 미사용
}
/** 카탈로그 노출 제품 (is_listed && is_active, sort_order 순) */
export async function getListedProducts(supabase: SupabaseClient): Promise<ProductRow[]> {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('is_listed', true)
.eq('is_active', true)
.order('sort_order')
.order('id');
if (error) throw error;
return (data ?? []) as ProductRow[];
}
export async function getProductById(supabase: SupabaseClient, id: string): Promise<ProductRow | null> {
const { data, error } = await supabase.from('products').select('*').eq('id', id).maybeSingle();
if (error) throw error;
return (data as ProductRow) ?? null;
}
/** 사용자의 결제 완료 product_id 목록 (orders 단일 소스) */
export async function getUserPaidProductIds(supabase: SupabaseClient, userId: string): Promise<string[]> {
const { data, error } = await supabase
.from('orders')
.select('product_id')
.eq('user_id', userId)
.eq('status', 'paid')
.not('product_id', 'is', null);
if (error) throw error;
return Array.from(new Set((data ?? []).map((r) => r.product_id as string)));
}
/** 접근 확장 포함 — 사용자가 다운로드 가능한 product_id 집합 */
export async function getUserAccessibleProductIds(supabase: SupabaseClient, userId: string): Promise<string[]> {
return expandProductAccess(await getUserPaidProductIds(supabase, userId));
}
export async function getFilesByProductIds(supabase: SupabaseClient, productIds: string[]): Promise<ProductFile[]> {
if (productIds.length === 0) return [];
const { data, error } = await supabase
.from('pack_files')
.select('*')
.in('product_id', productIds)
.is('deleted_at', null)
.order('product_id')
.order('sort_order');
if (error) throw error;
return (data ?? []) as ProductFile[];
}
export async function getFileById(supabase: SupabaseClient, id: string): Promise<ProductFile | null> {
const { data, error } = await supabase.from('pack_files').select('*').eq('id', id).maybeSingle();
if (error) throw error;
return (data as ProductFile) ?? null;
}
```
- [ ] **Step 2: `npm test` + `npm run build` 통과 확인**
- [ ] **Step 3: Commit**
```bash
git add lib/supabase/product-files.ts
git commit -m "feat(products): orders 기반 제품/파일 조회 헬퍼"
```
---
### Task 5: 주문 생성 API + 주문 메일 — `POST /api/orders`
**Files:**
- Create: `lib/order-emails.ts`
- Create: `app/api/orders/route.ts`
- [ ] **Step 1: `lib/order-emails.ts`** (Resend 패턴은 `app/api/contact/route.ts:74-100` 준용)
```typescript
import { Resend } from 'resend';
import type { ProductRow } from '@/lib/supabase/product-files';
const FROM = '쟁승메이드 <noreply@jaengseung-made.com>';
const ADMIN_EMAIL = 'bgg8988@gmail.com';
const BANK_INFO = '케이뱅크 100-116-337157 (예금주: 박재오)';
function resend() {
return new Resend(process.env.RESEND_API_KEY);
}
const won = (n: number) => `${n.toLocaleString('ko-KR')}`;
/** 주문 접수: 고객 안내 + 관리자 알림 (실패해도 주문은 유효 — 호출부에서 try/catch) */
export async function sendOrderReceivedEmails(opts: {
orderId: string;
product: ProductRow;
customerEmail: string;
depositorName: string;
}) {
const { orderId, product, customerEmail, depositorName } = opts;
const r = resend();
await r.emails.send({
from: FROM,
to: [customerEmail],
subject: `[쟁승메이드] 주문 접수 — ${product.name}`,
html: `
<h2>주문이 접수되었습니다</h2>
<p><strong>${product.name}</strong> · ${won(product.price)}</p>
<p>아래 계좌로 입금해 주시면, 확인 후 마이페이지에서 바로 다운로드하실 수 있습니다.</p>
<p style="font-size:16px;"><strong>${BANK_INFO}</strong></p>
<p>입금자명: <strong>${depositorName}</strong></p>
<hr />
<p style="color:#666;font-size:12px;">주문번호 ${orderId} · 입금 확인은 영업시간 기준 최대 24시간 소요됩니다.</p>
`,
});
await r.emails.send({
from: FROM,
to: [ADMIN_EMAIL],
subject: `[쟁승메이드] 신규 주문(입금 대기) — ${product.name}`,
html: `
<h2>신규 계좌이체 주문</h2>
<p>상품: ${product.name} (${won(product.price)})</p>
<p>주문자 이메일: ${customerEmail} / 입금자명: ${depositorName}</p>
<p>주문번호: ${orderId}</p>
<p>입금 확인 후 <a href="https://jaengseung-made.com/admin/orders">관리자 주문 페이지</a>에서 [입금 확인]을 눌러주세요.</p>
`,
});
}
/** 입금 확인: 고객에게 다운로드 활성화 안내 */
export async function sendOrderPaidEmail(opts: { product: ProductRow; customerEmail: string }) {
const { product, customerEmail } = opts;
await resend().emails.send({
from: FROM,
to: [customerEmail],
subject: `[쟁승메이드] 입금 확인 완료 — ${product.name} 다운로드 안내`,
html: `
<h2>입금이 확인되었습니다</h2>
<p><strong>${product.name}</strong> 다운로드가 활성화되었습니다.</p>
<p><a href="https://jaengseung-made.com/mypage?tab=products">마이페이지 → 내 제품</a>에서 바로 받으실 수 있습니다.</p>
<p style="color:#666;font-size:12px;">다운로드 링크는 클릭 시 4시간 동안 유효하며, 만료되면 다시 누르면 됩니다.</p>
`,
});
}
```
- [ ] **Step 2: `app/api/orders/route.ts`**
```typescript
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
import { sendOrderReceivedEmails } from '@/lib/order-emails';
export const runtime = 'nodejs';
export async function POST(request: Request) {
// 1) 로그인 확인 (기존 /api/packs/sign-link의 SSR 클라이언트 생성 패턴을 그대로 복사해 사용)
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} } },
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
// 2) 입력 검증
const body = await request.json().catch(() => null);
const productId = typeof body?.productId === 'string' ? body.productId.slice(0, 64) : '';
const depositorName = typeof body?.depositorName === 'string' ? body.depositorName.trim().slice(0, 40) : '';
if (!productId || !depositorName) {
return NextResponse.json({ error: 'productId와 depositorName이 필요합니다' }, { status: 400 });
}
// 3) 상품 검증 (가격은 서버 DB 기준 — 클라이언트 금액 신뢰 금지)
const admin = createAdminClient();
const product = await getProductById(admin, productId);
if (!product || !product.is_active) {
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
}
// 4) 중복 pending 주문 방지 (같은 상품 미입금 주문 있으면 재사용)
const { data: existing } = await admin
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', productId)
.eq('status', 'pending')
.limit(1);
if (existing && existing.length > 0) {
return NextResponse.json({ orderId: existing[0].id, reused: true });
}
// 5) 주문 생성
const { data: order, error } = await admin
.from('orders')
.insert({
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: { method: 'bank_transfer', depositor_name: depositorName },
})
.select('id')
.single();
if (error || !order) {
return NextResponse.json({ error: '주문 생성에 실패했습니다' }, { status: 500 });
}
// 6) 메일 (실패해도 주문 유효)
try {
await sendOrderReceivedEmails({
orderId: order.id,
product,
customerEmail: user.email ?? '',
depositorName,
});
} catch (e) {
console.error('order email failed', e);
}
return NextResponse.json({ orderId: order.id });
}
```
- [ ] **Step 3: `npm run build` 통과 + dev에서 curl 검증** — 비로그인 POST → 401 확인:
`curl -s -X POST localhost:3000/api/orders -H "Content-Type: application/json" -d '{"productId":"music_pro","depositorName":"t"}'``{"error":"로그인이 필요합니다"}`
- [ ] **Step 4: Commit**
```bash
git add lib/order-emails.ts app/api/orders/route.ts
git commit -m "feat(orders): 계좌이체 주문 생성 API + 접수/입금확인 메일"
```
---
### Task 6: 다운로드 검증 orders 기반 교체 (list-mine·sign-link) + mypage 내 제품 그룹핑
**Files:**
- Modify: `app/api/packs/list-mine/route.ts`
- Modify: `app/api/packs/sign-link/route.ts`
- Modify: `app/mypage/page.tsx` (내 제품 탭 렌더만)
- [ ] **Step 1: list-mine 교체** — contact_requests/extractPackTier 제거, 응답을 제품 그룹으로:
기존 인증·SSR 클라이언트 생성 코드는 유지. 본문 교체:
```typescript
// (기존 user 확인 이후)
const admin = createAdminClient();
const productIds = await getUserAccessibleProductIds(admin, user.id);
if (productIds.length === 0) return NextResponse.json({ products: [] });
const [files, { data: products }] = await Promise.all([
getFilesByProductIds(admin, productIds),
admin.from('products').select('id, name').in('id', productIds),
]);
const nameMap = new Map((products ?? []).map((p) => [p.id, p.name as string]));
const grouped = new Map<string, { id: string; name: string; files: typeof files }>();
for (const f of files) {
if (!f.product_id) continue;
if (!grouped.has(f.product_id)) {
grouped.set(f.product_id, { id: f.product_id, name: nameMap.get(f.product_id) ?? f.product_id, files: [] });
}
grouped.get(f.product_id)!.files.push(f);
}
return NextResponse.json({ products: Array.from(grouped.values()) });
```
- [ ] **Step 2: sign-link 교체** — tier 검증부만 교체 (HMAC signLink 호출·인증·구조는 무수정):
```typescript
// (기존 user 확인 이후 — contact_requests/extractPackTier 블록 삭제하고)
const admin = createAdminClient();
const accessible = await getUserAccessibleProductIds(admin, user.id);
if (accessible.length === 0) {
return NextResponse.json({ error: '구매 내역이 없거나 입금 확인 전입니다' }, { status: 403 });
}
const file = await getFileById(admin, fileId);
if (!file || file.deleted_at || !file.product_id || !accessible.includes(file.product_id)) {
return NextResponse.json({ error: '구매한 제품의 파일이 아닙니다' }, { status: 403 });
}
// (이후 기존 signLink 호출 그대로)
```
- [ ] **Step 3: mypage 내 제품 탭**`/api/packs/list-mine` 응답 형식 변경(`{files}``{products}`)에 맞춰 렌더를 제품별 그룹 카드로 변경. `handleDownload`(sign-link 호출)는 무수정. `extractPackTier`·`packOrders` 기반 분기 제거하고 `products.length === 0`일 때 빈 상태("구매한 제품이 없습니다" + /products CTA). pending 주문이 있으면 "입금 확인 대기 중" 안내(orders는 기존 mypage state 재사용).
- [ ] **Step 4: `npm test` + `npm run build` + dev 검증** — 비로그인 list-mine → `{ products: [] }`, sign-link → 401
- [ ] **Step 5: Commit**
```bash
git add app/api/packs/list-mine/route.ts app/api/packs/sign-link/route.ts app/mypage/page.tsx
git commit -m "feat(downloads): 다운로드 검증을 orders 단일 소스로 교체 + 내 제품 제품별 그룹핑"
```
---
### Task 7: `/products` 동적 카탈로그 + `/products/[id]` 상세 + 구매 모달
> **designer + soft-skill 로드 필수.** 톤은 `app/page.tsx`·`app/outsourcing/page.tsx` 기준.
**Files:**
- Modify: `app/products/page.tsx` (스텁 → 동적)
- Create: `app/products/[id]/page.tsx`
- Create: `app/components/BankTransferModal.tsx` (신규 — 레거시 `PurchaseAgreementModal`은 무수정 보존)
- [ ] **Step 1: `app/products/page.tsx`** — 서버 컴포넌트. `getListedProducts(createAdminClient())`로 조회:
- 제품 있음: 카드 그리드 (name, description, `₩가격.toLocaleString`, features 상위 3개, [자세히 보기 → /products/[id]])
- 제품 없음: 기존 스텁의 "출시 준비 중" 섹션 유지 (구매 방식 3단계 안내 포함)
- 페이지 하단: 구매 안내 3단계(계좌이체 → 입금 확인 → 마이페이지 다운로드) + 외주 CTA
- [ ] **Step 2: `app/products/[id]/page.tsx`** — 서버 컴포넌트 + 클라이언트 구매 버튼:
- `getProductById`로 조회, `!product || !product.is_listed || !product.is_active``notFound()`
- 구성: 제품명·가격·description_long(또는 description)·features 리스트·포함 파일 안내("구매 후 마이페이지에서 즉시 다운로드")·환불 정책 링크(/legal/refund)
- [구매하기] 버튼 → `BankTransferModal` (클라이언트 래퍼 컴포넌트로 분리: `'use client'` + 모달 상태)
- `generateMetadata`로 제품명 title
- [ ] **Step 3: `app/components/BankTransferModal.tsx`** — props: `{ product: { id, name, price }, isOpen, onClose }`:
1. 로그인 확인: `createClient().auth.getSession()` — 미로그인이면 "로그인 후 구매할 수 있습니다" + [로그인 → `/login?next=/products/${id}`] (구매 진행 차단)
2. 입금자명 입력 + 약관 동의 체크(이용약관·환불정책 링크) → [주문하기] → `POST /api/orders { productId, depositorName }`
3. 성공 화면: 케이뱅크 100-116-337157 (예금주 박재오) + 금액 + "입금 확인 후 마이페이지에서 다운로드" + [마이페이지로]
4. 에러 시 메시지 표시. `--jsm-*` 토큰, role="dialog", Esc 닫기 (Phase 1 TopNav 드로어 패턴 준용)
- 참고: `/login``?next=` 파라미터를 지원하는지 `app/login/page.tsx` 확인 — 미지원이면 로그인 성공 리다이렉트에 next 처리 추가(로직 최소 수정 허용)
- [ ] **Step 4: 검증**`npm run build` + dev: `/products` 200(준비 중 — 아직 is_listed 제품 없음), 존재하지 않는 `/products/foo` 404
- [ ] **Step 5: Commit**
```bash
git add app/products/ app/components/BankTransferModal.tsx app/login/page.tsx
git commit -m "feat(products): 동적 카탈로그·상세 페이지 + 계좌이체 구매 모달"
```
---
### Task 8: `/admin/orders` — 주문 관리 + 입금 확인
**Files:**
- Create: `app/api/admin/orders/route.ts`
- Create: `app/admin/orders/page.tsx`
- Modify: `app/admin/components/AdminSidebar.tsx` (NAV_ITEMS)
- [ ] **Step 1: API**`app/api/admin/orders/route.ts` (인증은 `app/api/admin/services/route.ts:8-12`의 checkAuth 패턴 복사):
```typescript
// GET: 주문 목록 (최근 200건) — products.name 조인 + 주문자 이메일
export async function GET() {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = createAdminClient();
const { data, error } = await supabase
.from('orders')
.select('id, user_id, product_id, amount, status, metadata, created_at, products(name), profiles(email)')
.order('created_at', { ascending: false })
.limit(200);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ orders: data ?? [] });
}
// PATCH: 상태 변경. 'paid' 전환 시 고객 메일 발송
export async function PATCH(request: Request) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, status } = await request.json();
if (!['pending', 'paid', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'invalid status' }, { status: 400 });
}
const supabase = createAdminClient();
const { data: order, error } = await supabase
.from('orders')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select('id, product_id, user_id')
.single();
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
if (status === 'paid' && order.product_id && order.user_id) {
try {
const product = await getProductById(supabase, order.product_id);
const { data: profile } = await supabase.from('profiles').select('email').eq('id', order.user_id).maybeSingle();
if (product && profile?.email) await sendOrderPaidEmail({ product, customerEmail: profile.email });
} catch (e) { console.error('paid email failed', e); }
}
return NextResponse.json({ success: true });
}
```
(주의: `products(name)`/`profiles(email)` 중첩 select가 FK 관계로 동작하는지 구현 시 확인 — 안 되면 2쿼리로 분리)
- [ ] **Step 2: 페이지**`app/admin/orders/page.tsx`: `app/admin/contacts/page.tsx` 구조(필터 탭 + 리스트 + 상세 패널) 준용:
- 필터: 전체 / 입금 대기(pending) / 완료(paid) / 취소(cancelled)
- 행: 상품명 · 금액 · 주문자 이메일 · 입금자명(metadata.depositor_name) · 주문일 · 상태 뱃지
- pending 행에 [입금 확인] 원클릭 버튼 (confirm 다이얼로그 → PATCH paid) + [취소] 버튼
- paid 행은 "다운로드 활성" 표시
- [ ] **Step 3: AdminSidebar** — NAV_ITEMS의 '문의 내역' 위에 `{ href: '/admin/orders', label: '주문 관리' }` 추가
- [ ] **Step 4: 검증** — build + dev: 비인증 GET `/api/admin/orders` → 401, admin 페이지 컴파일 확인
- [ ] **Step 5: Commit**
```bash
git add app/api/admin/orders/route.ts app/admin/orders/page.tsx app/admin/components/AdminSidebar.tsx
git commit -m "feat(admin): 주문 관리 페이지 — 입금 확인 원클릭 + 다운로드 활성화 메일"
```
---
### Task 9: `/admin/products` — 제품 CRUD + 파일 배정 (packs 통합)
**Files:**
- Create: `app/api/admin/products/route.ts`
- Create: `app/admin/products/page.tsx`
- Modify: `app/api/admin/packs/route.ts` (PATCH에 product_id 허용 1줄)
- Modify: `app/admin/components/AdminSidebar.tsx`
- [ ] **Step 1: 제품 CRUD API**`app/api/admin/products/route.ts` (checkAuth 패턴):
- GET: 전체 제품 (sort_order, id 순)
- POST: `{ id, name, description, description_long, price, features, is_listed, sort_order }` — id는 `^[a-z0-9_]{2,40}$` 검증, category 기본 'software', pay_method 기본 'bank_transfer', upsert 아닌 insert (중복 id → 409)
- PATCH: `{ id, ...partial }` — name/description/description_long/price/features/is_listed/is_active/sort_order만 화이트리스트 업데이트
- DELETE 없음 (is_active=false로 비활성 — YAGNI)
- [ ] **Step 2: packs PATCH 확장**`app/api/admin/packs/route.ts`의 PATCH updates 화이트리스트에 추가:
```typescript
if (typeof product_id === 'string' || product_id === null) updates.product_id = product_id;
```
(request json 구조분해에 product_id 추가. 나머지 무수정)
- [ ] **Step 3: 페이지**`app/admin/products/page.tsx`:
- 상단: 제품 목록 테이블 (name, price, is_listed 토글, sort_order, [편집])
- [새 제품] 폼: id/name/price/description/description_long/features(줄바꿈 구분 textarea→배열)/is_listed
- 제품 선택 시 하단에 **파일 섹션**: 해당 product_id의 pack_files 목록 + **파일 업로드**(기존 `/admin/packs`의 업로드 플로우 재사용 — upload-url 발급 시 tier는 'starter' 고정 전달) + 업로드 완료 후 목록 새로고침하여 **미배정 파일(product_id null 또는 다른 제품) 드롭다운으로 현재 제품에 배정**(packs PATCH product_id)
- 기존 `/admin/packs` 페이지·메뉴는 유지 (음악 팩 레거시 관리용) — 안내 문구 한 줄 추가: "신규 제품 파일은 제품 관리에서 배정하세요"
- [ ] **Step 4: AdminSidebar** — '주문 관리' 아래 `{ href: '/admin/products', label: '제품 관리' }` 추가
- [ ] **Step 5: 검증** — build + 비인증 401 + admin 렌더 확인
- [ ] **Step 6: Commit**
```bash
git add app/api/admin/products/route.ts app/admin/products/page.tsx app/api/admin/packs/route.ts app/admin/components/AdminSidebar.tsx
git commit -m "feat(admin): 제품 관리 — CRUD + 파일 업로드·제품 배정"
```
---
### Task 10: 메인 페이지 소프트웨어 진열 동적 교체
**Files:**
- Modify: `app/page.tsx` (섹션 6 — Phase 2 교체 주석 지점)
- [ ] **Step 1:** `getListedProducts(createAdminClient())` 호출(서버 컴포넌트라 직접 가능)로:
- 제품 1개 이상: 상위 3개 카드(name, description, 가격, → /products/[id]) + [전체 보기 → /products]
- 0개: 기존 "출시 준비 중" 정적 구성 유지 (조건 분기)
- [ ] **Step 2:** build + dev에서 `/` 200 확인 (DB 조회 실패 시에도 페이지가 죽지 않도록 try/catch로 빈 배열 폴백)
- [ ] **Step 3: Commit**
```bash
git add app/page.tsx
git commit -m "feat(home): 소프트웨어 진열 섹션 products 동적 연동"
```
---
### Task 11: Phase 2 E2E 검증
- [ ] **Step 1: 자동 검증**`npm test`(vitest) + `npm run build` + prod 서버 curl:
- `/products` 200, `/products/없는상품` 404
- POST `/api/orders` 비로그인 401 / GET `/api/admin/orders` 비인증 401 / `/api/admin/products` 비인증 401
- `/api/packs/list-mine` 비로그인 `{ products: [] }`
- `/` 200 (동적 섹션 폴백 포함)
- [ ] **Step 2: 수동 E2E (시나리오 B — 운영 DB에 마이그레이션 적용 후)**
1. admin/products에서 테스트 제품 등록(is_listed) + 작은 파일 업로드·배정
2. 일반 계정으로 /products → 상세 → 구매 모달 → 주문 → 접수 메일 2통 수신
3. /admin/orders [입금 확인] → 활성화 메일 수신
4. mypage 내 제품 → 다운로드 → DSM 링크로 파일 수신
5. **회귀**: 기존 음악 팩 구매 계정으로 mypage 다운로드 정상 (이관 검증)
- [ ] **Step 3: 최종 Commit + 보고**
---
## 🔖 재개 체크포인트 (2026-06-12 세션 한도 중단)
- **완료**: Task 1~8 (P2-1~P2-8) — 커밋 `cf89e8c``dc5e9d4`. vitest·마이그레이션 SQL 2건·product-files 헬퍼·/api/orders·다운로드 orders 교체·/products 카탈로그·/admin/orders 전부 리뷰 승인 완료
- **진행 중**: Task 9 (P2-9 /admin/products) — 구현 서브에이전트가 파일 작성 후 한도로 중단. **working tree에 미커밋 상태**: `app/admin/products/`(신규), `app/api/admin/products/`(신규), `app/admin/components/AdminSidebar.tsx`·`app/admin/packs/page.tsx`·`app/api/admin/packs/route.ts`(수정)
- **재개 절차**: ① 미커밋 파일 정독(미완성 함수/문법 확인) → ② `npm test`+`npm run build` → ③ curl 401/200 검증 → ④ 커밋 → ⑤ 스펙+품질 리뷰 → Task 10(메인 동적화)·Task 11(E2E)로 계속
- **주의**: 운영 DB 마이그레이션(Task 2·3 SQL) 미적용 — 머지·배포 전 양쪽 DB 적용 필수
## Phase 3 예고 (별도 플랜)
외주 고객 포털: 단계형 의뢰 폼, contact_requests 상태 머신 확장 + public_token, quotes FK 연결, /track/[token], 자동 이메일, admin 의뢰·견적 통합 뷰.

View File

@@ -0,0 +1,157 @@
# 쟁승메이드 사이트 리뉴얼 — 외주 의뢰 + 완성 소프트웨어 판매 중심 재구성
- **작성일**: 2026-06-11
- **상태**: CEO 승인 완료
- **목표**: 사이트 정체성을 "외주 개발 의뢰 수주 + 완성 소프트웨어 판매" 2축으로 재정립.
전문 B2B 에이전시 수준의 UI/UX 풀 리디자인, 외주 의뢰 플로우 고도화(고객 포털 포함),
NAS 기반 결제 후 다운로드 구조의 오류 제거.
---
## 0. 확정된 핵심 결정 (CEO)
| 항목 | 결정 |
|------|------|
| 결제 | **계좌이체 중심**으로 정리. PG(PortOne)는 코드만 보존, 유료라 추후 연결 |
| 기존 서비스 (사주·음악 팩·로또·설문/gyeol·웹사이트 샘플 등) | **숨김** — 관리자만 토글·접근 가능 |
| 외주 플로우 | **고객 포털까지** — 상태 추적 + 견적 열람/수락/거절 |
| 디자인 | **풀 리디자인** — B2B 개발 에이전시 톤 (Pretendard, slate+딥블루) |
| 제품 판매 | **범용 제품 시스템** — 음악 팩 전용 pack 인프라를 일반 제품으로 확장 |
## 1. 아키텍처 결정 (대안 비교 결과)
### 1-1. 외주 의뢰 데이터 모델 — `contact_requests` 확장 (채택)
- 기존 테이블에 상태 머신 + `public_token` 컬럼 추가, `quotes.contact_request_id` FK로 연결.
- 대안(projects 테이블 신설)은 이중 마이그레이션·전면 재작성 부담으로 기각.
- **이유**: NAS self-host 전환 진행 중(클라우드/셀프호스트 양쪽 마이그레이션 필요) → 변경 폭 최소가 안전.
### 1-2. 제품 파일 시스템 — `products` 확장 + `pack_files.product_id` 연결 (채택)
- `products`에 설명·`pay_method`·`is_hidden`(또는 is_active 재활용)·상세 콘텐츠 컬럼 추가.
- `pack_files``product_id` FK 추가, 기존 음악 팩 파일은 음악 제품에 연결.
- 대안(신규 product_files 테이블)은 web-backend(packs-lab) 수정이 필요해 기각.
- **이유**: DSM 링크 발급·HMAC·admin 업로드 인프라(web-backend) **무수정** 재사용.
### 1-3. 구매 식별 — `orders` 테이블 단일 소스 (채택)
- 현재 `contact_requests.service` 문자열 파싱("구매 신청: AI 음악 마스터 팩 · 프로") 의존 → 제거.
- 모든 구매는 `orders(product_id, status, method)` 기준. 기존 구매자는 이관 스크립트로 orders 생성.
## 2. 정보 구조 (IA)
```
/ 메인 — Hero(외주+소프트웨어 2축), 서비스 소개, 개발 프로세스,
포트폴리오 하이라이트, 제품 진열, 신뢰 요소(7년차 경력·실적), CTA
/outsourcing 외주 의뢰 — 포트폴리오, 진행 프로세스 안내, 단계형 의뢰 폼
/products 완성 소프트웨어 카탈로그 (is_active 제품만)
/products/[id] 제품 상세 + 계좌이체 구매
/track/[token] 비회원 의뢰 상태 추적 (이메일 링크로 접근)
/login 리디자인 (Supabase Auth 유지: 이메일+Google OAuth)
/mypage 리디자인 — 탭: 프로필 · 내 의뢰 · 내 제품/다운로드 · 주문 내역
/quote/[token] 공개 견적서 (유지, 수락/거절 동기화 보강)
/legal/* 유지 (톤만 리디자인)
/admin/* 주문 관리(신설) + 제품 관리(packs 일반화) + 의뢰·견적 통합 뷰 + 숨김 서비스 토글
```
### 숨김 처리 대상
`/work/saju*`, `/music/*`, `/gyeol`, `/packages`, 로또 관련 노출 전부.
- `service_settings` 토글 기반: 비활성 시 일반 사용자에게 404(notFound), **admin 세션(admin_token 쿠키)이면 접근 허용**.
- 데이터(saju_records, 구독 등)는 보존 — 토글 재활성 시 복귀.
- 기존 `/work/freelance``/outsourcing` redirect 추가 (next.config redirects).
### 외주로 통합
- 웹사이트 제작(`/work/website`)은 외주 개발의 한 유형으로 `/outsourcing`에 통합.
샘플 포트폴리오(`/work/website/samples/*`)는 포트폴리오 자료로 재사용 (숨김 아님).
## 3. 디자인 시스템 (풀 리디자인)
- **레이아웃**: 사이드바 대시보드형 → **상단 네비 + 풋터의 기업 사이트형**. 모바일은 햄버거 드로어.
- **타이포**: Jua 제거 → **Pretendard** (next/font local 또는 CDN).
- **컬러**: slate 뉴트럴 베이스 + 딥블루 포인트 1색. 카드/섹션/버튼/뱃지 공통 컴포넌트 정비.
- **품질 가드**: 구현 시 designer·soft-skill 스킬 적용 — generic AI 패턴(과한 그래디언트, 이모지 남발, 보라색 남용) 차단. 이미지 없이 타이포·여백·SVG로 완성도.
- admin 영역도 동일 토큰 적용하되 기능 우선의 밀도 높은 레이아웃.
## 4. 제품 구매 → 다운로드 흐름
```
제품 상세(/products/[id]) → [구매하기] → 로그인 확인(미로그인 → /login?next=)
→ 계좌이체 신청 모달 (케이뱅크 100-116-337157 박재오 안내, 약관 동의)
→ orders 생성 (product_id, amount, status='pending', metadata.method='bank_transfer')
→ 고객: 접수 확인 메일(Resend) / 관리자: 신규 주문 알림 메일
→ /admin/orders: 입금 확인 [완료] 원클릭 → status='paid'
→ 고객: "다운로드가 활성화되었습니다" 메일
→ /mypage '내 제품': orders(status='paid') 기준 제품별 파일 목록
→ 다운로드 클릭 → /api/packs/sign-link (검증을 orders 기반으로 교체)
→ web-backend HMAC → DSM 공유 링크 (4시간 만료, 만료 시 재클릭으로 재발급)
```
- `PurchaseAgreementModal`을 범용 제품용으로 일반화.
- `PaymentButton`(PortOne)은 보존하되 `products.pay_method='bank_transfer'`일 때 미노출.
추후 PG 계약 시 제품별 플래그만 변경하면 카드 결제 활성.
- admin 팩 업로드 UI(/admin/packs)는 제품 관리(/admin/products) 안으로 통합 — 제품 선택 → 파일 업로드.
- tier(starter/pro/master) 개념은 음악 팩 하위 호환용으로 유지하되, 신규 제품은 product_id 직접 매칭.
## 5. 외주 의뢰 플로우 (고객 포털 포함)
### 상태 머신 (contact_requests.status)
`pending(접수) → reviewing(검토중) → quoted(견적발송) → accepted(수주)/on_hold(보류) → in_progress(진행중) → completed(완료)`
(+ `cancelled`)
### 흐름
```
[고객] /outsourcing 단계형 의뢰 폼
단계: ① 프로젝트 유형 → ② 예산/희망 일정 → ③ 상세 요구사항 → ④ 연락처(로그인 시 자동 채움)
→ contact_requests 생성 (public_token 발급) + 접수 확인 메일(추적 링크 /track/[token] 포함)
+ 관리자 알림 메일 (기존 Resend 흐름 유지)
[관리자] /admin 의뢰 상세 (contacts + quotes 통합 뷰)
→ 상태 변경 드롭다운 (상태 머신 순서 강제)
→ 견적서 작성 시 contact_request_id 자동 연결
→ [견적 발송] 버튼 → quotes.status='sent' + 고객 메일 자동 발송(견적 링크 포함)
+ contact_requests.status='quoted' 동기화
[고객] /track/[token] (비회원) 또는 /mypage '내 의뢰' (회원)
→ 상태 타임라인 표시 + 연결된 견적서 열람
→ 견적 수락/거절 버튼 → quotes.status='accepted'/'rejected'
+ contact_requests.status 동기화('accepted'/'on_hold') + 관리자 알림 메일
```
- rate limit·XSS 방지 등 기존 `/api/contact` 보안 로직 유지.
- 견적서 작성 시 DB 자동 등록 원칙 유지 ([[feedback_quotes_admin]]).
## 6. DB 마이그레이션 (클라우드 + self-host 양쪽 적용)
신규 마이그레이션 SQL 파일 1~2개로 작성 (`supabase/migrations/2026-06-11-*.sql`):
1. `products`: `description_long TEXT`, `pay_method TEXT DEFAULT 'bank_transfer'`, `features JSONB`, `sort_order INT` 추가
2. `pack_files`: `product_id TEXT REFERENCES products(id)` 추가 + 기존 음악 팩 파일 product_id 백필
3. `contact_requests`: `public_token TEXT UNIQUE`, `budget TEXT`, `timeline TEXT`, `project_type TEXT` 추가
+ status CHECK 갱신 (새 상태 머신)
4. `quotes`: `contact_request_id UUID REFERENCES contact_requests(id)` 추가
5. 기존 음악 팩 구매자(contact_requests 'completed' + "구매 신청:" 패턴) → orders 이관 스크립트
6. RLS: `/track/[token]`용 조회는 서버(admin client)에서만 — 테이블 직접 노출 없음
**운영 주의**: 현 운영=Vercel+클라우드 Supabase, NAS self-host로 전환 직전(Phase 6 ③ DNS 전환 대기).
마이그레이션은 **양쪽 DB에 동일 순서로 적용**하고, 이관 스크립트는 멱등하게 작성.
## 7. 구현 단계
| Phase | 내용 | 검증 |
|-------|------|------|
| **1. 디자인 시스템 + IA** | 디자인 토큰·공통 컴포넌트, 상단 네비 레이아웃 전환, 메인·/outsourcing 리디자인, 숨김 서비스 처리, /login·/mypage 리디자인 | 전 페이지 렌더·반응형·숨김 토글 확인 |
| **2. 제품 판매 시스템** | DB 마이그레이션, /products 카탈로그·상세, 계좌이체 구매 플로우, /admin/orders·/admin/products, sign-link 검증 교체, 기존 구매자 이관 | 회원 구매→입금확인→다운로드 E2E |
| **3. 외주 고객 포털** | 단계형 폼, contact↔quote 연결, 상태 머신, /track/[token], 자동 이메일, admin 통합 뷰 | 비회원 의뢰→견적→수락 E2E |
## 8. E2E 검증 시나리오 (각 Phase 종료 시 수동)
- **A. 외주 (비회원)**: 의뢰 폼 제출 → 접수 메일 수신 → /track 접속 → admin 견적 발송 → 메일 링크로 견적 열람 → 수락 → admin 알림 확인
- **B. 제품 구매 (회원)**: 회원가입/로그인 → 제품 구매 신청 → 접수 메일 → admin 입금 확인 → 다운로드 활성 메일 → mypage 다운로드 → DSM 링크 파일 수신
- **C. 숨김 서비스**: 일반 사용자 404 확인 / admin 세션 접근 확인 / 토글 재활성 복귀 확인
- **D. 회귀**: 기존 음악 팩 구매자 mypage 다운로드 정상 동작 (이관 후)
## 9. 의도적 제외 (이번 범위 아님)
- PG(카드) 결제 활성화 — 플래그 구조만 준비
- 관리자 RBAC(권한 분화)
- 구독 정기 결제
- ZIP 일괄 다운로드, 다운로드 횟수 제한
- 사주 SaaS 도메인 분리 (별도 트랙)
- web-backend(packs-lab) 코드 수정

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { expandProductAccess, MUSIC_PRODUCT_CHAIN } from '@/lib/product-access';
describe('expandProductAccess', () => {
it('일반 제품은 자기 자신만 반환', () => {
expect(expandProductAccess(['lotto_tool'])).toEqual(['lotto_tool']);
});
it('music_pro는 starter를 포함', () => {
expect(expandProductAccess(['music_pro']).sort()).toEqual(['music_pro', 'music_starter'].sort());
});
it('music_master는 전 tier 포함', () => {
expect(expandProductAccess(['music_master']).sort()).toEqual(
['music_master', 'music_pro', 'music_starter'].sort(),
);
});
it('중복 입력은 중복 없이 반환', () => {
expect(expandProductAccess(['music_pro', 'music_starter']).sort()).toEqual(
['music_pro', 'music_starter'].sort(),
);
});
it('빈 입력은 빈 배열', () => {
expect(expandProductAccess([])).toEqual([]);
});
});

71
lib/order-emails.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Resend } from 'resend';
import { escapeHtml } from '@/lib/security';
import type { ProductRow } from '@/lib/supabase/product-files';
const FROM = '쟁승메이드 <noreply@jaengseung-made.com>';
const ADMIN_EMAIL = 'bgg8988@gmail.com';
const BANK_INFO = '케이뱅크 100-116-337157 (예금주: 박재오)';
function resend() {
return new Resend(process.env.RESEND_API_KEY);
}
const won = (n: number) => `${n.toLocaleString('ko-KR')}`;
/** 주문 접수: 고객 안내 + 관리자 알림 (실패해도 주문은 유효 — 호출부에서 try/catch) */
export async function sendOrderReceivedEmails(opts: {
orderId: string;
product: ProductRow;
customerEmail: string;
depositorName: string;
}) {
const { orderId, product, customerEmail, depositorName } = opts;
// XSS 방지: 사용자 입력값 이스케이프
const safeDepositorName = escapeHtml(depositorName);
const safeCustomerEmail = escapeHtml(customerEmail);
const r = resend();
await r.emails.send({
from: FROM,
to: [customerEmail],
subject: `[쟁승메이드] 주문 접수 — ${product.name}`,
html: `
<h2>주문이 접수되었습니다</h2>
<p><strong>${product.name}</strong> · ${won(product.price)}</p>
<p>아래 계좌로 입금해 주시면, 확인 후 마이페이지에서 바로 다운로드하실 수 있습니다.</p>
<p style="font-size:16px;"><strong>${BANK_INFO}</strong></p>
<p>입금자명: <strong>${safeDepositorName}</strong></p>
<hr />
<p style="color:#666;font-size:12px;">주문번호 ${orderId} · 입금 확인은 영업시간 기준 최대 24시간 소요됩니다.</p>
`,
});
await r.emails.send({
from: FROM,
to: [ADMIN_EMAIL],
subject: `[쟁승메이드] 신규 주문(입금 대기) — ${product.name}`,
html: `
<h2>신규 계좌이체 주문</h2>
<p>상품: ${product.name} (${won(product.price)})</p>
<p>주문자 이메일: ${safeCustomerEmail} / 입금자명: ${safeDepositorName}</p>
<p>주문번호: ${orderId}</p>
<p>입금 확인 후 <a href="https://jaengseung-made.com/admin/orders">관리자 주문 페이지</a>에서 [입금 확인]을 눌러주세요.</p>
`,
});
}
/** 입금 확인: 고객에게 다운로드 활성화 안내 */
export async function sendOrderPaidEmail(opts: { product: ProductRow; customerEmail: string }) {
const { product, customerEmail } = opts;
await resend().emails.send({
from: FROM,
to: [customerEmail],
subject: `[쟁승메이드] 입금 확인 완료 — ${product.name} 다운로드 안내`,
html: `
<h2>입금이 확인되었습니다</h2>
<p><strong>${product.name}</strong> 다운로드가 활성화되었습니다.</p>
<p><a href="https://jaengseung-made.com/mypage?tab=products">마이페이지 → 내 제품</a>에서 바로 받으실 수 있습니다.</p>
<p style="color:#666;font-size:12px;">다운로드 링크는 클릭 시 4시간 동안 유효하며, 만료되면 다시 누르면 됩니다.</p>
`,
});
}

17
lib/product-access.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* orders 기반 제품 접근 확장.
* 음악 팩 상위 tier는 하위 tier 파일도 포함(하위 호환) — 신규 제품은 1:1.
*/
export const MUSIC_PRODUCT_CHAIN: Record<string, string[]> = {
music_starter: ['music_starter'],
music_pro: ['music_pro', 'music_starter'],
music_master: ['music_master', 'music_pro', 'music_starter'],
};
export function expandProductAccess(paidProductIds: string[]): string[] {
const out = new Set<string>();
for (const id of paidProductIds) {
for (const expanded of MUSIC_PRODUCT_CHAIN[id] ?? [id]) out.add(expanded);
}
return Array.from(out);
}

31
lib/service-visibility.ts Normal file
View File

@@ -0,0 +1,31 @@
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto';
/**
* 서비스 노출 여부. admin_token 세션이면 항상 true.
* service_settings 조회 실패(테이블 미생성 등) 시 안전하게 숨김(false).
* @warning 레거시 숨김 전용 — 일반 공개 서비스(products 등) 가드에 재사용 금지.
* fail-closed 정책이라 DB 일시 장애 시 404가 됨. 캐싱 없음(매 렌더 DB 조회).
*/
export async function isServiceVisible(id: HideableService): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
if (token && verifyAdminTokenNode(token)) return true;
try {
const supabase = createAdminClient();
const { data, error } = await supabase
.from('service_settings')
.select('is_active')
.eq('id', id)
.maybeSingle();
if (error || !data) return false;
return data.is_active === true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,84 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { expandProductAccess } from '@/lib/product-access';
export interface ProductRow {
id: string;
name: string;
description: string | null;
description_long: string | null;
price: number;
category: string;
is_active: boolean;
is_listed: boolean;
sort_order: number;
features: string[] | null;
pay_method: string;
}
export interface ProductFile {
id: string;
product_id: string | null;
label: string;
file_path: string;
filename: string;
size_bytes: number;
sort_order: number;
uploaded_at: string;
deleted_at: string | null;
min_tier: string; // 레거시 컬럼 — 신규 로직에서는 미사용
}
/** 카탈로그 노출 제품 (is_listed && is_active, sort_order 순) */
export async function getListedProducts(supabase: SupabaseClient): Promise<ProductRow[]> {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('is_listed', true)
.eq('is_active', true)
.order('sort_order')
.order('id');
if (error) throw error;
return (data ?? []) as ProductRow[];
}
export async function getProductById(supabase: SupabaseClient, id: string): Promise<ProductRow | null> {
const { data, error } = await supabase.from('products').select('*').eq('id', id).maybeSingle();
if (error) throw error;
return (data as ProductRow) ?? null;
}
/** 사용자의 결제 완료 product_id 목록 (orders 단일 소스) */
export async function getUserPaidProductIds(supabase: SupabaseClient, userId: string): Promise<string[]> {
const { data, error } = await supabase
.from('orders')
.select('product_id')
.eq('user_id', userId)
.eq('status', 'paid')
.not('product_id', 'is', null);
if (error) throw error;
return Array.from(new Set((data ?? []).map((r) => r.product_id as string)));
}
/** 접근 확장 포함 — 사용자가 다운로드 가능한 product_id 집합 */
export async function getUserAccessibleProductIds(supabase: SupabaseClient, userId: string): Promise<string[]> {
return expandProductAccess(await getUserPaidProductIds(supabase, userId));
}
export async function getFilesByProductIds(supabase: SupabaseClient, productIds: string[]): Promise<ProductFile[]> {
if (productIds.length === 0) return [];
const { data, error } = await supabase
.from('pack_files')
.select('*')
.in('product_id', productIds)
.is('deleted_at', null)
.order('product_id')
.order('sort_order');
if (error) throw error;
return (data ?? []) as ProductFile[];
}
export async function getFileById(supabase: SupabaseClient, id: string): Promise<ProductFile | null> {
const { data, error } = await supabase.from('pack_files').select('*').eq('id', id).maybeSingle();
if (error) throw error;
return (data as ProductFile) ?? null;
}

View File

@@ -39,13 +39,18 @@ const nextConfig: NextConfig = {
{ source: '/services/music', destination: '/music/packs', permanent: true },
{ source: '/services/music/samples', destination: '/music/samples', permanent: true },
{ source: '/studio', destination: '/music/studio', permanent: true },
// 커스텀 외주 마이그
{ source: '/freelance', destination: '/work/freelance', permanent: true },
{ source: '/services/website', destination: '/work/website', permanent: true },
// 커스텀 외주 마이그 (2026-06-11 리뉴얼: work 라우트 → /outsourcing 통합)
{ source: '/work/freelance', destination: '/outsourcing', permanent: true },
{ source: '/work', destination: '/outsourcing', permanent: true },
{ source: '/work/website', destination: '/outsourcing', permanent: true },
// 구 URL은 체인 없이 한 번에 /outsourcing 으로
{ source: '/freelance', destination: '/outsourcing', permanent: true },
{ source: '/services/website', destination: '/outsourcing', permanent: true },
// 샘플 데모는 포트폴리오용으로 잔존 → samples 라우트 유지
{ source: '/services/website/samples/:slug', destination: '/work/website/samples/:slug', permanent: true },
// 블로그 자동화 폐기(2026-05-29 재정의): 기존 URL은 제품 라인 허브로 안내
{ source: '/services/blog', destination: '/work', permanent: true },
{ source: '/work/blog', destination: '/work', permanent: true },
// 블로그 자동화 폐기(2026-05-29 재정의): 기존 URL은 외주 허브로 안내
{ source: '/services/blog', destination: '/outsourcing', permanent: true },
{ source: '/work/blog', destination: '/outsourcing', permanent: true },
// 사주 마이그 (단순 URL, 카탈로그 spec은 보류)
{ source: '/saju', destination: '/work/saju', permanent: true },
{ source: '/saju/input', destination: '/work/saju/input', permanent: true },

1162
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.79.0",
@@ -22,6 +23,7 @@
"lunar-javascript": "^1.7.7",
"next": "^16.2.6",
"openai": "^6.21.0",
"pretendard": "^1.3.9",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^9.0.1",
@@ -38,6 +40,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.8"
}
}

View File

@@ -0,0 +1,15 @@
-- 2026-06-11 리뉴얼: 레거시 서비스 숨김 토글 시드
-- service_settings: 이미 있으면 숨김 상태로 갱신 (2026-06 리뉴얼 의도 강제) — 멱등
INSERT INTO service_settings (id, name, description, is_active, order_index)
VALUES
('saju', 'AI 사주 분석', '사주 입력 및 AI 해석 (레거시)', false, 101),
('music', 'AI 음악 팩', '음악 가이드 패키지·샘플·스튜디오', false, 102),
('gyeol', 'CONTOUR 설문', '/gyeol PMF 설문', false, 103),
('packages', 'SaaS 제품 허브(구)', '구 /packages 페이지', false, 104),
('lotto', '로또 추천', '로또 번호 추천 노출', false, 105)
ON CONFLICT (id) DO UPDATE SET
is_active = EXCLUDED.is_active,
name = EXCLUDED.name,
description = EXCLUDED.description,
order_index = EXCLUDED.order_index,
updated_at = now();

View File

@@ -0,0 +1,32 @@
-- 2026-06-12 Phase 2: 음악 팩 구매(contact_requests 문자열) → orders 이관
-- 대상: status='completed' AND user_id 보유 AND '구매 신청:' + '· 입문/프로/마스터' 패턴
-- (user_id 없는 행은 현행 다운로드 시스템도 서빙하지 않으므로 이관 대상 아님)
INSERT INTO orders (user_id, product_id, amount, status, metadata, created_at)
SELECT
cr.user_id,
p.id,
p.price,
'paid',
jsonb_build_object(
'method', 'bank_transfer',
'source_contact_id', cr.id::text,
'migrated_at', now()::text,
'original_service', cr.service
),
cr.created_at
FROM contact_requests cr
JOIN products p ON p.id = CASE
WHEN cr.service LIKE '%· 입문' THEN 'music_starter'
WHEN cr.service LIKE '%· 프로' THEN 'music_pro'
WHEN cr.service LIKE '%· 마스터' THEN 'music_master'
END
WHERE cr.status = 'completed'
AND cr.user_id IS NOT NULL
AND cr.service LIKE '구매 신청:%'
AND NOT EXISTS (
SELECT 1 FROM orders o WHERE o.metadata->>'source_contact_id' = cr.id::text
);
-- 검증: 이관 건수 = 대상 건수 확인
-- SELECT count(*) FROM orders WHERE metadata ? 'source_contact_id';
-- SELECT count(*) FROM contact_requests WHERE status='completed' AND user_id IS NOT NULL AND service LIKE '구매 신청:%' AND (service LIKE '%· 입문' OR service LIKE '%· 프로' OR service LIKE '%· 마스터');

View File

@@ -0,0 +1,20 @@
-- 2026-06-12 Phase 2: products 범용 제품 시스템 확장
-- (1) products 컬럼 확장
ALTER TABLE products ADD COLUMN IF NOT EXISTS description_long text;
ALTER TABLE products ADD COLUMN IF NOT EXISTS features jsonb; -- string[] 형태
ALTER TABLE products ADD COLUMN IF NOT EXISTS pay_method text NOT NULL DEFAULT 'bank_transfer';
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_listed boolean NOT NULL DEFAULT false; -- /products 카탈로그 노출
ALTER TABLE products ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
-- (2) 음악 팩 제품 시드 (다운로드 권한 매핑용 — 카탈로그 비노출 is_listed=false)
INSERT INTO products (id, name, description, price, category, is_active, is_listed)
VALUES
('music_starter', 'AI 음악 생성 개발 가이드 (입문)', '음악 팩 입문 — 레거시', 39000, 'software', true, false),
('music_pro', 'AI 음악 생성 개발 가이드 (프로)', '음악 팩 프로 — 레거시', 99000, 'software', true, false),
('music_master', 'AI 음악 생성 개발 가이드 (마스터)', '음악 팩 마스터 — 레거시', 149000, 'software', true, false)
ON CONFLICT (id) DO NOTHING;
-- (3) pack_files → 제품 연결
ALTER TABLE pack_files ADD COLUMN IF NOT EXISTS product_id text REFERENCES products(id);
UPDATE pack_files SET product_id = 'music_' || min_tier WHERE product_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_pack_files_product ON pack_files (product_id) WHERE deleted_at IS NULL;

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: { include: ['lib/**/*.test.ts'] },
resolve: { alias: { '@': path.resolve(__dirname, '.') } },
});