Merge pull request #1 from gahusb/feature/renewal-phase1

리뉴얼 Phase 1: 외주+소프트웨어 2축 풀 리디자인 + 레거시 서비스 숨김
This commit is contained in:
gahusb
2026-06-11 03:13:35 +09:00
committed by GitHub
30 changed files with 3204 additions and 1911 deletions

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 },
];

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

@@ -73,117 +73,77 @@ function LoginForm() {
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 +151,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 +324,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,433 @@
'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';
const BEFORE = [
'작곡 공부에만 최소 6개월 소요',
'영상 편집 프로그램 학습의 높은 장벽',
'항상 불안한 저작권 위반 위험',
'곡 하나 완성에 드는 수백만 원의 외주비',
// 쟁승메이드 메인 — 외주 개발 + 완성 소프트웨어 2축 랜딩 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
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>
);
}
export default function Home() {
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 테이블 기반 동적 진열로 교체 예정.
현재는 출시 전 정적 안내만 노출한다. */}
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
<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>

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

@@ -0,0 +1,163 @@
import Link from 'next/link';
import type { Metadata } from 'next';
// TODO(Phase 2): products 테이블 연동 후 동적 카탈로그로 교체 예정.
// 현재는 404 방지용 정적 스텁 페이지입니다.
export const metadata: Metadata = {
title: '완성 소프트웨어',
description:
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
};
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: '입금이 확인되면 메일로 안내해 드립니다. 보통 당일 처리됩니다.' },
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지에서 구매 내역을 확인하고 파일을 내려받습니다.' },
];
export default function ProductsPage() {
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>
{/* ─── 출시 준비 중 안내 ─── */}
<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,
}}
>
</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

@@ -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,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) 코드 수정

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

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

7
package-lock.json generated
View File

@@ -21,6 +21,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",
@@ -7958,6 +7959,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretendard": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/pretendard/-/pretendard-1.3.9.tgz",
"integrity": "sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw==",
"license": "OFL-1.1"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@@ -22,6 +22,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",

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