Compare commits
12 Commits
055469a2d5
...
b705f35c2d
| Author | SHA1 | Date | |
|---|---|---|---|
| b705f35c2d | |||
| 4cd4a50869 | |||
| 01c31e3e5d | |||
| e22622d36d | |||
| 186ae546f2 | |||
| eb1ecf0021 | |||
| 4b85c52cfe | |||
| 4223004c24 | |||
| bd13641f5e | |||
| 5cfa124d38 | |||
| 64259a85b5 | |||
| 70068ff3d7 |
@@ -16,9 +16,9 @@ const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const INPUT_STYLE = {
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
border: '1px solid var(--jsm-dark-line)',
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
} as const;
|
||||
|
||||
const PROJECT_TYPES = [
|
||||
@@ -195,13 +195,13 @@ export default function OutsourcingRequestForm() {
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-xl font-bold break-keep outline-none"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
의뢰가 접수되었습니다
|
||||
</h3>
|
||||
<p
|
||||
className="mt-3 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
영업일 2일 내 회신드립니다.
|
||||
</p>
|
||||
@@ -218,7 +218,7 @@ export default function OutsourcingRequestForm() {
|
||||
</Link>
|
||||
<p
|
||||
className="mt-3 text-xs leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
추적 링크를 이메일로도 보내드렸습니다.
|
||||
</p>
|
||||
@@ -232,7 +232,7 @@ export default function OutsourcingRequestForm() {
|
||||
const canAdvance = stepValid(step);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="jsm-dark-form">
|
||||
{/* 진행 표시기 */}
|
||||
<ol className="flex items-center gap-2 mb-7" aria-label="진행 단계">
|
||||
{STEPS.map((s, i) => {
|
||||
@@ -244,7 +244,7 @@ export default function OutsourcingRequestForm() {
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0 transition-colors"
|
||||
style={
|
||||
state === 'upcoming'
|
||||
? { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' }
|
||||
? { background: 'var(--jsm-dark-surface)', color: 'var(--jsm-dark-soft)', boxShadow: 'inset 0 0 0 1px var(--jsm-dark-line)' }
|
||||
: { background: 'var(--jsm-accent)', color: '#ffffff' }
|
||||
}
|
||||
aria-current={state === 'current' ? 'step' : undefined}
|
||||
@@ -255,7 +255,7 @@ export default function OutsourcingRequestForm() {
|
||||
className="text-xs font-semibold truncate hidden sm:inline"
|
||||
style={{
|
||||
color:
|
||||
state === 'upcoming' ? 'var(--jsm-ink-faint)' : 'var(--jsm-ink)',
|
||||
state === 'upcoming' ? 'var(--jsm-dark-soft)' : 'var(--jsm-dark-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -264,7 +264,7 @@ export default function OutsourcingRequestForm() {
|
||||
{i < STEPS.length - 1 && (
|
||||
<span
|
||||
className="w-4 sm:w-6 h-px shrink-0"
|
||||
style={{ background: 'var(--jsm-line)' }}
|
||||
style={{ background: 'var(--jsm-dark-line)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
@@ -281,13 +281,13 @@ export default function OutsourcingRequestForm() {
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
어떤 프로젝트인가요?
|
||||
</legend>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
가장 가까운 유형을 하나 선택해주세요.
|
||||
</p>
|
||||
@@ -303,12 +303,12 @@ export default function OutsourcingRequestForm() {
|
||||
className="px-4 py-3.5 rounded-lg text-sm font-semibold text-center break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
border: selected
|
||||
? '1px solid var(--jsm-accent)'
|
||||
: '1px solid var(--jsm-line)',
|
||||
? '1px solid var(--jsm-accent-bright)'
|
||||
: '1px solid var(--jsm-dark-line)',
|
||||
background: selected
|
||||
? 'var(--jsm-accent-soft)'
|
||||
: 'var(--jsm-surface)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
? 'rgba(96,165,250,0.12)'
|
||||
: 'var(--jsm-dark-surface)',
|
||||
color: selected ? 'var(--jsm-accent-bright)' : 'var(--jsm-dark-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -327,13 +327,13 @@ export default function OutsourcingRequestForm() {
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
예산과 일정을 알려주세요
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
대략적인 범위면 충분합니다. 정해지지 않았다면 미정을 선택하세요.
|
||||
</p>
|
||||
@@ -341,7 +341,7 @@ export default function OutsourcingRequestForm() {
|
||||
<fieldset className="mb-6">
|
||||
<legend
|
||||
className="text-sm font-semibold mb-2.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_BODY }}
|
||||
>
|
||||
예산
|
||||
</legend>
|
||||
@@ -360,7 +360,7 @@ export default function OutsourcingRequestForm() {
|
||||
<fieldset>
|
||||
<legend
|
||||
className="text-sm font-semibold mb-2.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_BODY }}
|
||||
>
|
||||
희망 일정
|
||||
</legend>
|
||||
@@ -385,13 +385,13 @@ export default function OutsourcingRequestForm() {
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
자세히 들려주세요
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
구체적일수록 정확한 견적이 가능합니다. 최소 10자 이상 작성해주세요.
|
||||
</p>
|
||||
@@ -413,7 +413,7 @@ export default function OutsourcingRequestForm() {
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{trimmedMessage.length}/10자 이상
|
||||
</p>
|
||||
@@ -427,13 +427,13 @@ export default function OutsourcingRequestForm() {
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
어디로 회신드릴까요?
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
영업일 2일 내에 회신드립니다.
|
||||
</p>
|
||||
@@ -443,7 +443,7 @@ export default function OutsourcingRequestForm() {
|
||||
<label
|
||||
htmlFor="req-name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_BODY }}
|
||||
>
|
||||
이름 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||
</label>
|
||||
@@ -465,7 +465,7 @@ export default function OutsourcingRequestForm() {
|
||||
<label
|
||||
htmlFor="req-email"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_BODY }}
|
||||
>
|
||||
이메일 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||
</label>
|
||||
@@ -487,7 +487,7 @@ export default function OutsourcingRequestForm() {
|
||||
<label
|
||||
htmlFor="req-phone"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_BODY }}
|
||||
>
|
||||
연락처
|
||||
</label>
|
||||
@@ -530,10 +530,10 @@ export default function OutsourcingRequestForm() {
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={submitting}
|
||||
className="px-5 py-3 rounded-lg text-sm font-semibold border transition-colors hover:bg-[var(--jsm-surface-alt)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-5 py-3 rounded-lg text-sm font-semibold border transition-colors hover:bg-[var(--jsm-dark-surface)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
...INPUT_STYLE,
|
||||
borderColor: 'var(--jsm-line)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -548,7 +548,7 @@ export default function OutsourcingRequestForm() {
|
||||
className="flex-1 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{
|
||||
background: !canAdvance || submitting
|
||||
? 'var(--jsm-ink-faint)'
|
||||
? 'var(--jsm-dark-line)'
|
||||
: 'var(--jsm-accent)',
|
||||
cursor: !canAdvance || submitting ? 'not-allowed' : 'pointer',
|
||||
...KOR_BODY,
|
||||
@@ -563,7 +563,7 @@ export default function OutsourcingRequestForm() {
|
||||
disabled={!canAdvance}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{
|
||||
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
|
||||
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-dark-line)',
|
||||
cursor: canAdvance ? 'pointer' : 'not-allowed',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
@@ -595,9 +595,9 @@ function Chip({
|
||||
aria-pressed={selected}
|
||||
className="px-4 py-2.5 rounded-lg text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
|
||||
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
border: selected ? '1px solid var(--jsm-accent-bright)' : '1px solid var(--jsm-dark-line)',
|
||||
background: selected ? 'rgba(96,165,250,0.12)' : 'var(--jsm-dark-surface)',
|
||||
color: selected ? 'var(--jsm-accent-bright)' : 'var(--jsm-dark-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -65,6 +65,18 @@ export default function TopNav() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
// 다크 라우트 판정
|
||||
const DARK_ROUTES = ['/', '/outsourcing'];
|
||||
const isDark = DARK_ROUTES.includes(pathname) || pathname.startsWith('/outsourcing/');
|
||||
|
||||
// 팔레트 헬퍼 — isDark 분기
|
||||
const ink = isDark ? 'var(--jsm-dark-ink)' : 'var(--jsm-ink)';
|
||||
const inkSoft = isDark ? 'var(--jsm-dark-soft)' : 'var(--jsm-ink-soft)';
|
||||
const surface = isDark ? 'var(--jsm-dark-bg)' : 'var(--jsm-surface)';
|
||||
const line = isDark ? 'var(--jsm-dark-line)' : 'var(--jsm-line)';
|
||||
const accent = isDark ? 'var(--jsm-accent-bright)' : 'var(--jsm-accent)';
|
||||
const accentBg = isDark ? 'rgba(96,165,250,0.12)' : 'var(--jsm-accent-soft)';
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
@@ -75,9 +87,13 @@ export default function TopNav() {
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
|
||||
style={{
|
||||
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',
|
||||
background: scrolled
|
||||
? (isDark ? 'rgba(7,13,26,0.85)' : 'var(--jsm-surface)')
|
||||
: 'transparent',
|
||||
borderBottom: scrolled
|
||||
? `1px solid ${line}`
|
||||
: '1px solid transparent',
|
||||
boxShadow: scrolled && !isDark ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
|
||||
}}
|
||||
>
|
||||
<nav className="max-w-7xl mx-auto flex w-full items-center justify-between h-16 px-6 lg:px-8">
|
||||
@@ -89,13 +105,13 @@ export default function TopNav() {
|
||||
>
|
||||
<span
|
||||
className="text-xl font-black tracking-tight"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="hidden sm:inline text-sm font-medium"
|
||||
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
@@ -109,8 +125,8 @@ export default function TopNav() {
|
||||
href={l.href}
|
||||
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink-soft)',
|
||||
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
|
||||
color: isActive(l.href) ? accent : inkSoft,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
@@ -127,14 +143,14 @@ export default function TopNav() {
|
||||
<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' }}
|
||||
style={{ color: inkSoft, 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' }}
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
@@ -143,7 +159,7 @@ export default function TopNav() {
|
||||
<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' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
@@ -167,7 +183,7 @@ export default function TopNav() {
|
||||
aria-label="메뉴 열기"
|
||||
aria-expanded={open}
|
||||
className="md:hidden p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink)' }}
|
||||
style={{ color: 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" />
|
||||
@@ -186,7 +202,7 @@ export default function TopNav() {
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
style={{ background: surface }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -195,18 +211,18 @@ export default function TopNav() {
|
||||
{/* 드로어 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 h-16 border-b"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
style={{ borderColor: line }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
style={{ color: inkSoft }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
@@ -215,7 +231,7 @@ export default function TopNav() {
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="메뉴 닫기"
|
||||
className="p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
style={{ color: inkSoft }}
|
||||
>
|
||||
<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" />
|
||||
@@ -231,8 +247,8 @@ export default function TopNav() {
|
||||
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',
|
||||
color: isActive(l.href) ? accent : ink,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
@@ -243,7 +259,7 @@ export default function TopNav() {
|
||||
|
||||
<div
|
||||
className="my-4 border-t"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
style={{ borderColor: line }}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
@@ -251,14 +267,14 @@ export default function TopNav() {
|
||||
<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' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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' }}
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
@@ -267,7 +283,7 @@ export default function TopNav() {
|
||||
<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' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
|
||||
76
app/components/deepfield/CountUp.tsx
Normal file
76
app/components/deepfield/CountUp.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** 카운트업 목표 숫자 */
|
||||
to: number;
|
||||
/** 숫자 앞에 붙는 고정 텍스트 (예: 없음) */
|
||||
prefix?: string;
|
||||
/** 숫자 뒤에 붙는 고정 텍스트 (예: '+') */
|
||||
suffix?: string;
|
||||
/** 애니메이션 길이(ms) — 기본 600 */
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IntersectionObserver 진입 시 0 → to 로 카운트업.
|
||||
* prefers-reduced-motion이면 즉시 최종값 표시(연출 생략).
|
||||
* transform/opacity가 아닌 textContent 변경이라 레이아웃 안정 위해 tabular-nums 권장.
|
||||
*/
|
||||
export default function CountUp({ to, prefix = '', suffix = '', duration = 600, className }: Props) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
let rafId = 0;
|
||||
let started = false;
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const run = () => {
|
||||
// reduced-motion: 즉시 최종값 (연출 생략)
|
||||
if (reduced) {
|
||||
setValue(to);
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min((now - start) / duration, 1);
|
||||
// easeOutCubic — 끝에서 부드럽게 안착
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
setValue(Math.round(eased * to));
|
||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !started) {
|
||||
started = true;
|
||||
run();
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.4 },
|
||||
);
|
||||
io.observe(el);
|
||||
|
||||
return () => {
|
||||
io.disconnect();
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [to, duration]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{prefix}
|
||||
{value.toLocaleString('ko-KR')}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
329
app/components/deepfield/HeroField.tsx
Normal file
329
app/components/deepfield/HeroField.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// 타입만 정적 import — 번들에 코드가 들어가지 않음 (import type)
|
||||
import type * as THREE from 'three';
|
||||
|
||||
import { useFieldMode } from './useFieldMode';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 2광원 radial 그래디언트.
|
||||
* static 모드 단독 비주얼이자, full/lite에서 캔버스 아래에 항상 깔리는 베이스.
|
||||
* (WebGL 로딩 전/실패 시에도 비주얼 공백 없음)
|
||||
*/
|
||||
function StaticField() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: 'var(--jsm-dark-bg, #070d1a)',
|
||||
backgroundImage: [
|
||||
// 광원1: 우상단 — accent blue-700
|
||||
'radial-gradient(60% 55% at 75% 25%, rgba(29,78,216,0.45) 0%, transparent 45%)',
|
||||
// 광원2: 좌하단 — bright blue (sky-400)
|
||||
'radial-gradient(55% 50% at 15% 85%, rgba(56,189,248,0.16) 0%, transparent 40%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── 셰이더 ─────────────────────────
|
||||
|
||||
const VERTEX_SHADER = /* glsl */ `
|
||||
uniform float uTime;
|
||||
uniform vec2 uMouse; // NDC (-1..1), lite/static에선 사실상 미사용
|
||||
uniform float uMouseAmp; // 커서 자기장 세기 (lite=0)
|
||||
uniform float uScroll; // 0..1, 진행될수록 흩어짐
|
||||
uniform float uPixelRatio;
|
||||
|
||||
attribute float aScale; // 파티클별 기본 크기 (1.5~3px)
|
||||
attribute float aSeed; // 드리프트 위상 분산
|
||||
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
// 색: #60a5fa(밝은) ↔ #1d4ed8(딥) 보간
|
||||
const vec3 C_BRIGHT = vec3(0.376, 0.647, 0.980); // #60a5fa
|
||||
const vec3 C_DEEP = vec3(0.114, 0.306, 0.847); // #1d4ed8
|
||||
|
||||
void main() {
|
||||
vec3 pos = position;
|
||||
|
||||
// 미세 유영 — 사인 노이즈 (드리프트)
|
||||
float t = uTime * 0.18 + aSeed * 6.2831853;
|
||||
pos.x += sin(t) * 0.06;
|
||||
pos.y += cos(t * 0.9) * 0.06;
|
||||
pos.z += sin(t * 0.7) * 0.04;
|
||||
|
||||
// 스크롤 — 진행될수록 바깥으로 밀려 흩어짐
|
||||
pos.xy += normalize(pos.xy + 0.0001) * uScroll * 0.9;
|
||||
|
||||
// 커서 자기장 — 화면 평면 기준 거리로 부드럽게 밀어냄
|
||||
if (uMouseAmp > 0.0) {
|
||||
vec2 toP = pos.xy - uMouse * 1.6;
|
||||
float d = length(toP);
|
||||
float radius = 0.7;
|
||||
float push = smoothstep(radius, 0.0, d); // 가까울수록 1
|
||||
pos.xy += normalize(toP + 0.0001) * push * 0.35 * uMouseAmp;
|
||||
pos.z += push * 0.2 * uMouseAmp;
|
||||
}
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
|
||||
// 크기: 원근(-mvPosition.z) 반영 + DPR
|
||||
gl_PointSize = aScale * uPixelRatio * (300.0 / -mvPosition.z);
|
||||
|
||||
// 색: seed로 두 파랑 사이 보간
|
||||
vColor = mix(C_DEEP, C_BRIGHT, aSeed);
|
||||
|
||||
// 불투명도: 스크롤로 소멸, 깊이로 약간 페이드
|
||||
float depthFade = smoothstep(-3.0, 0.5, mvPosition.z);
|
||||
vAlpha = (1.0 - uScroll) * (0.45 + depthFade * 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = /* glsl */ `
|
||||
precision mediump float;
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
// 원형 소프트 포인트 — 가장자리 부드럽게
|
||||
vec2 c = gl_PointCoord - vec2(0.5);
|
||||
float dist = length(c);
|
||||
if (dist > 0.5) discard;
|
||||
float soft = smoothstep(0.5, 0.05, dist);
|
||||
gl_FragColor = vec4(vColor, soft * vAlpha);
|
||||
}
|
||||
`;
|
||||
|
||||
// ───────────────────────── 컴포넌트 ─────────────────────────
|
||||
|
||||
export default function HeroField({ className }: Props) {
|
||||
const mode = useFieldMode();
|
||||
// WebGL 초기화 실패 시 static으로 강등
|
||||
const [failed, setFailed] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const effectiveMode = failed ? 'static' : mode;
|
||||
const animated = effectiveMode === 'full' || effectiveMode === 'lite';
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const isFull = effectiveMode === 'full';
|
||||
const COUNT = isFull ? 3000 : 800;
|
||||
|
||||
let disposed = false;
|
||||
let rafId = 0;
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
let scene: THREE.Scene | null = null;
|
||||
let camera: THREE.PerspectiveCamera | null = null;
|
||||
let geometry: THREE.BufferGeometry | null = null;
|
||||
let material: THREE.ShaderMaterial | null = null;
|
||||
let points: THREE.Points | null = null;
|
||||
let io: IntersectionObserver | null = null;
|
||||
|
||||
// 가시성/뷰포트 상태 — 둘 다 OK일 때만 rAF 돌림
|
||||
let pageVisible = document.visibilityState !== 'hidden';
|
||||
let inView = true;
|
||||
|
||||
// 마우스 스무딩 (NDC)
|
||||
const mouse = { x: 0, y: 0 };
|
||||
const mouseTarget = { x: 0, y: 0 };
|
||||
|
||||
// 핸들러 참조 (cleanup용)
|
||||
let onMouseMove: ((e: MouseEvent) => void) | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
let onVisibility: (() => void) | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (rafId || disposed) return;
|
||||
if (!pageVisible || !inView) return;
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
const stop = () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
};
|
||||
|
||||
let loop: (now: number) => void = () => {};
|
||||
|
||||
(async () => {
|
||||
let THREE_NS: typeof THREE;
|
||||
try {
|
||||
// three는 dynamic import만 — 메인 번들 분리
|
||||
THREE_NS = await import('three');
|
||||
} catch {
|
||||
if (!disposed) setFailed(true);
|
||||
return;
|
||||
}
|
||||
if (disposed || !canvasRef.current) return;
|
||||
|
||||
try {
|
||||
const width = canvas.clientWidth || window.innerWidth;
|
||||
const height = canvas.clientHeight || window.innerHeight;
|
||||
|
||||
renderer = new THREE_NS.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: true, // 섹션 bg가 비치도록 투명
|
||||
antialias: false,
|
||||
powerPreference: 'low-power',
|
||||
});
|
||||
// lite는 DPR 1 고정, full은 최대 2로 제한
|
||||
const dpr = isFull ? Math.min(window.devicePixelRatio || 1, 2) : 1;
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(width, height, false);
|
||||
renderer.setClearColor(0x000000, 0); // 완전 투명
|
||||
|
||||
scene = new THREE_NS.Scene();
|
||||
camera = new THREE_NS.PerspectiveCamera(60, width / height, 0.1, 100);
|
||||
camera.position.z = 3;
|
||||
|
||||
// 얕은 3D 슬랩: 화면을 덮는 균일 분포 + 약간의 노이즈, z 약간 분산
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const scales = new Float32Array(COUNT);
|
||||
const seeds = new Float32Array(COUNT);
|
||||
const SPREAD_X = 5.0;
|
||||
const SPREAD_Y = 3.2;
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
positions[i * 3 + 0] = (Math.random() - 0.5) * SPREAD_X;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * SPREAD_Y;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 1.2; // 얕은 z 분산
|
||||
scales[i] = 1.5 + Math.random() * 1.5; // 1.5~3px
|
||||
seeds[i] = Math.random();
|
||||
}
|
||||
|
||||
geometry = new THREE_NS.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE_NS.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('aScale', new THREE_NS.BufferAttribute(scales, 1));
|
||||
geometry.setAttribute('aSeed', new THREE_NS.BufferAttribute(seeds, 1));
|
||||
|
||||
material = new THREE_NS.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uMouse: { value: new THREE_NS.Vector2(0, 0) },
|
||||
uMouseAmp: { value: isFull ? 1 : 0 }, // lite는 커서 반응 off
|
||||
uScroll: { value: 0 },
|
||||
uPixelRatio: { value: dpr },
|
||||
},
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: FRAGMENT_SHADER,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
blending: THREE_NS.AdditiveBlending, // 미세한 글로우
|
||||
});
|
||||
|
||||
points = new THREE_NS.Points(geometry, material);
|
||||
scene.add(points);
|
||||
|
||||
// ── 핸들러 ──
|
||||
if (isFull) {
|
||||
onMouseMove = (e: MouseEvent) => {
|
||||
mouseTarget.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseTarget.y = -((e.clientY / window.innerHeight) * 2 - 1);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||
}
|
||||
|
||||
onResize = () => {
|
||||
if (!renderer || !camera || !canvasRef.current) return;
|
||||
const w = canvasRef.current.clientWidth || window.innerWidth;
|
||||
const h = canvasRef.current.clientHeight || window.innerHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h, false);
|
||||
};
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
|
||||
onVisibility = () => {
|
||||
pageVisible = document.visibilityState !== 'hidden';
|
||||
if (pageVisible) start();
|
||||
else stop();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
// 뷰포트 밖이면 rAF 정지
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
inView = entries[0]?.isIntersecting ?? false;
|
||||
if (inView) start();
|
||||
else stop();
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
io.observe(canvas);
|
||||
|
||||
// ── 렌더 루프 ──
|
||||
const clock = new THREE_NS.Clock();
|
||||
loop = () => {
|
||||
rafId = 0;
|
||||
if (disposed || !renderer || !scene || !camera || !material) return;
|
||||
|
||||
const elapsed = clock.getElapsedTime();
|
||||
const u = material.uniforms;
|
||||
u.uTime.value = elapsed;
|
||||
|
||||
// 커서 스무딩 (lerp 0.08)
|
||||
mouse.x += (mouseTarget.x - mouse.x) * 0.08;
|
||||
mouse.y += (mouseTarget.y - mouse.y) * 0.08;
|
||||
(u.uMouse.value as THREE.Vector2).set(mouse.x, mouse.y);
|
||||
|
||||
// 스크롤 진행도 0~1 clamp
|
||||
const scrollT = Math.min(
|
||||
Math.max(window.scrollY / (window.innerHeight || 1), 0),
|
||||
1,
|
||||
);
|
||||
u.uScroll.value = scrollT;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
start();
|
||||
};
|
||||
|
||||
start();
|
||||
} catch {
|
||||
if (!disposed) setFailed(true);
|
||||
}
|
||||
})();
|
||||
|
||||
// ── cleanup: rAF cancel + 리스너 제거 + dispose ──
|
||||
return () => {
|
||||
disposed = true;
|
||||
stop();
|
||||
if (onMouseMove) window.removeEventListener('mousemove', onMouseMove);
|
||||
if (onResize) window.removeEventListener('resize', onResize);
|
||||
if (onVisibility) document.removeEventListener('visibilitychange', onVisibility);
|
||||
io?.disconnect();
|
||||
geometry?.dispose();
|
||||
material?.dispose();
|
||||
renderer?.dispose();
|
||||
scene = null;
|
||||
camera = null;
|
||||
points = null;
|
||||
};
|
||||
}, [animated, effectiveMode]);
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-none absolute inset-0 overflow-hidden ${className ?? ''}`}>
|
||||
{/* 정적 그래디언트 — 항상 캔버스 아래에 깔림 */}
|
||||
<StaticField />
|
||||
{animated && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
app/components/deepfield/ScrollReveal.tsx
Normal file
58
app/components/deepfield/ScrollReveal.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** 등장 지연(ms) — 연속 항목 스태거용 */
|
||||
delay?: number;
|
||||
/** 'fade-up'(기본) | 'fade' | 'draw'(선 그리기용 — width 확장) */
|
||||
variant?: 'fade-up' | 'fade' | 'draw';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScrollReveal({ children, delay = 0, variant = 'fade-up', className }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// reduced-motion: 즉시 표시 (연출 생략)
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setShown(true);
|
||||
return;
|
||||
}
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setShown(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
const hidden =
|
||||
variant === 'fade' ? 'opacity-0' :
|
||||
variant === 'draw' ? 'opacity-0 [transform:scaleX(0)] origin-left' :
|
||||
'opacity-0 translate-y-6';
|
||||
|
||||
const visible =
|
||||
variant === 'draw' ? 'opacity-100 [transform:scaleX(1)]' :
|
||||
variant === 'fade' ? 'opacity-100' :
|
||||
'opacity-100 translate-y-0';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? visible : hidden}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
app/components/deepfield/ShowcaseCard.tsx
Normal file
347
app/components/deepfield/ShowcaseCard.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { ShowcaseSlot } from '@/lib/showcase';
|
||||
|
||||
interface Props {
|
||||
slot: ShowcaseSlot;
|
||||
size?: 'feature' | 'standard';
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ───────────────────────── 시드 PRNG (결정적) ─────────────────────────
|
||||
|
||||
/** slug → 32bit 정수 시드 (cyrb-ish 해시) */
|
||||
function hashSlug(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32 — 시드 하나로 결정적 난수열 생성 */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** #rrggbb → {r,g,b} */
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── 패턴 3종 (정적 텍스처) ─────────────────────────
|
||||
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
/** 1. 등고선 — accent 동심 곡선 흐름 */
|
||||
function drawContour(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const cx = w * (0.3 + rng() * 0.4);
|
||||
const cy = h * (0.3 + rng() * 0.4);
|
||||
const rings = 9 + Math.floor(rng() * 5);
|
||||
const step = Math.max(w, h) / rings;
|
||||
const wobble = 0.12 + rng() * 0.1;
|
||||
const phase = rng() * Math.PI * 2;
|
||||
ctx.lineWidth = 1.25;
|
||||
for (let i = 1; i <= rings; i++) {
|
||||
const baseR = i * step;
|
||||
const alpha = 0.1 + (1 - i / rings) * 0.08; // 0.10~0.18
|
||||
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${alpha})`;
|
||||
ctx.beginPath();
|
||||
const segs = 72;
|
||||
for (let s = 0; s <= segs; s++) {
|
||||
const a = (s / segs) * Math.PI * 2;
|
||||
const r = baseR * (1 + Math.sin(a * 3 + phase + i * 0.6) * wobble);
|
||||
const x = cx + Math.cos(a) * r;
|
||||
const y = cy + Math.sin(a) * r * 0.82;
|
||||
if (s === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** 2. 격자 왜곡 — 미세하게 휘어진 그리드 라인 */
|
||||
function drawGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const cols = 7 + Math.floor(rng() * 4);
|
||||
const rows = 5 + Math.floor(rng() * 4);
|
||||
const amp = 6 + rng() * 10;
|
||||
const fx = 1.5 + rng() * 2;
|
||||
const fy = 1.5 + rng() * 2;
|
||||
const phase = rng() * Math.PI * 2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.14)`;
|
||||
// 세로선
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const baseX = (i / cols) * w;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const ny = j / 40;
|
||||
const y = ny * h;
|
||||
const x = baseX + Math.sin(ny * Math.PI * fy + phase + i * 0.4) * amp;
|
||||
if (j === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// 가로선
|
||||
for (let i = 0; i <= rows; i++) {
|
||||
const baseY = (i / rows) * h;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const nx = j / 40;
|
||||
const x = nx * w;
|
||||
const y = baseY + Math.cos(nx * Math.PI * fx + phase + i * 0.4) * amp;
|
||||
if (j === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** 3. 도트 필드 — 밀도 그라데이션 도트 */
|
||||
function drawDots(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const gap = 16 + rng() * 8;
|
||||
const ox = rng() * Math.PI * 2;
|
||||
const oy = rng() * Math.PI * 2;
|
||||
// 밀도 중심 (가장 진한 지점)
|
||||
const dcx = w * (0.2 + rng() * 0.6);
|
||||
const dcy = h * (0.2 + rng() * 0.6);
|
||||
const maxD = Math.hypot(w, h);
|
||||
for (let y = gap * 0.5; y < h; y += gap) {
|
||||
for (let x = gap * 0.5; x < w; x += gap) {
|
||||
const jx = Math.sin((y / gap) * 1.3 + ox) * 2.5;
|
||||
const jy = Math.cos((x / gap) * 1.3 + oy) * 2.5;
|
||||
const px = x + jx;
|
||||
const py = y + jy;
|
||||
const d = Math.hypot(px - dcx, py - dcy) / maxD; // 0~~1
|
||||
const density = 1 - d; // 중심부 1
|
||||
const alpha = 0.06 + density * 0.16; // 0.06~0.22
|
||||
const radius = 0.9 + density * 1.8;
|
||||
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PATTERNS = [drawContour, drawGrid, drawDots];
|
||||
|
||||
// ───────────────────────── 컴포넌트 ─────────────────────────
|
||||
|
||||
export default function ShowcaseCard({ slot, size = 'standard', index }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
// 슬러그 시드 — 결정적 패턴 선택/파라미터
|
||||
const seed = hashSlug(slot.slug);
|
||||
const patternType = seed % PATTERNS.length;
|
||||
|
||||
// 캔버스 1회 정적 렌더 (DPR 반영, 애니메이션 루프 없음)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
if (w === 0 || h === 0) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const rng = mulberry32(seed);
|
||||
const c = hexToRgb(slot.accent);
|
||||
PATTERNS[patternType](ctx, w, h, rng, c);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
// 컨테이너 리사이즈 시 재렌더 (정적 — 루프 아님)
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, [seed, patternType, slot.accent]);
|
||||
|
||||
// 호버 시차 — 리스너/rAF는 hover 중에만 가동 (상시 rAF 금지)
|
||||
useEffect(() => {
|
||||
if (!hovered) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const canvas = canvasRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
if (!canvas || !wrap) return;
|
||||
|
||||
let rafId = 0;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
|
||||
const apply = () => {
|
||||
rafId = 0;
|
||||
canvas.style.transform = `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px)`;
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5; // -0.5~0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
tx = nx * 12; // ±6px
|
||||
ty = ny * 12;
|
||||
if (!rafId) rafId = requestAnimationFrame(apply);
|
||||
};
|
||||
|
||||
wrap.addEventListener('mousemove', onMove);
|
||||
return () => {
|
||||
wrap.removeEventListener('mousemove', onMove);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
canvas.style.transform = '';
|
||||
};
|
||||
}, [hovered]);
|
||||
|
||||
const isFeature = size === 'feature';
|
||||
const isLink = Boolean(slot.href);
|
||||
|
||||
// 타일 본체 (링크/div 공통)
|
||||
const tile = (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
data-index={index}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={[
|
||||
'group/card relative isolate h-full w-full overflow-hidden rounded-2xl',
|
||||
'transition-[transform,box-shadow] duration-500',
|
||||
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
||||
'motion-safe:hover:scale-[1.03]',
|
||||
isLink ? 'cursor-pointer' : 'cursor-default',
|
||||
isFeature ? 'aspect-[16/10]' : 'aspect-[4/3]',
|
||||
].join(' ')}
|
||||
style={
|
||||
{
|
||||
'--card-accent': slot.accent,
|
||||
backgroundImage: `linear-gradient(135deg, ${slot.palette[0]}, ${slot.palette[1]})`,
|
||||
// 기본 보더는 다크 라인, hover 시 accent 점등 + 코너 글로우 (인라인 hover는 className으로)
|
||||
boxShadow: hovered
|
||||
? `0 0 0 1px ${slot.accent}, 0 18px 50px -20px ${slot.accent}66, inset 0 0 60px -30px ${slot.accent}80`
|
||||
: '0 0 0 1px var(--jsm-dark-line, rgba(148,163,184,0.14))',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 제너러티브 텍스처 (정적) */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full will-change-transform"
|
||||
/>
|
||||
|
||||
{/* 하단 스크림 — 텍스트 가독성 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(7,13,26,0.92) 0%, rgba(7,13,26,0.55) 45%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 텍스트 레이어 */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1.5 p-5 sm:p-6">
|
||||
<span
|
||||
className="font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||
style={{ color: slot.accent }}
|
||||
>
|
||||
{slot.label}
|
||||
</span>
|
||||
<h3
|
||||
className={[
|
||||
'font-bold leading-snug [word-break:keep-all]',
|
||||
isFeature ? 'text-xl sm:text-2xl' : 'text-lg',
|
||||
].join(' ')}
|
||||
style={{ color: 'var(--jsm-dark-ink, #f8fafc)' }}
|
||||
>
|
||||
{slot.title}
|
||||
</h3>
|
||||
<p
|
||||
className="line-clamp-1 text-sm [word-break:keep-all]"
|
||||
style={{ color: 'var(--jsm-dark-soft, #94a3b8)' }}
|
||||
>
|
||||
{slot.desc}
|
||||
</p>
|
||||
|
||||
{isLink && (
|
||||
<span
|
||||
className="mt-1 inline-flex items-center gap-1.5 text-[13px] font-medium transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-1"
|
||||
style={{ color: slot.accent }}
|
||||
>
|
||||
데모 보기
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-0.5"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M13 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLink) {
|
||||
return (
|
||||
<Link href={slot.href!} aria-label={slot.title} className="block h-full w-full">
|
||||
{tile}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
61
app/components/deepfield/ShowcaseGrid.tsx
Normal file
61
app/components/deepfield/ShowcaseGrid.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ShowcaseSlot } from '@/lib/showcase';
|
||||
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import ShowcaseCard from './ShowcaseCard';
|
||||
|
||||
interface Props {
|
||||
slots: ShowcaseSlot[];
|
||||
variant: 'home' | 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* home: 6슬롯 지그재그 — wide(col-span-2) 3장 + standard 3장 = 9셀(3×3 완전 충전)
|
||||
* row1: [0 feature span2][1 std]
|
||||
* row2: [2 std][3 feature span2]
|
||||
* row3: [4 feature span2][5 std]
|
||||
* 모바일은 1col 전부 standard.
|
||||
* full: 8슬롯 데스크톱 2col 균등(standard), 모바일 1col.
|
||||
*/
|
||||
export default function ShowcaseGrid({ slots, variant }: Props) {
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 md:gap-6">
|
||||
{slots.slice(0, 8).map((slot, i) => (
|
||||
<ScrollReveal key={slot.slug} delay={i * 80}>
|
||||
<ShowcaseCard slot={slot} size="standard" index={i} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// home — 6슬롯 (3col 그리드)
|
||||
const items = slots.slice(0, 6);
|
||||
|
||||
// 데스크톱 흐름 (3col) — wide(span-2) 3장 + standard 3장 = 9셀, 빈 칸 없음
|
||||
// row1: [0 feature span2 좌][1 std 우] → 2+1 = 3
|
||||
// row2: [2 std 좌][3 feature span2 우] → 1+2 = 3
|
||||
// row3: [4 feature span2 좌][5 std 우] → 2+1 = 3
|
||||
// 자동 흐름(auto-placement)이 위 순서를 보장하므로 col-start 불필요.
|
||||
const layout: Array<{ span: string; size: 'feature' | 'standard' }> = [
|
||||
{ span: 'md:col-span-2', size: 'feature' }, // 0 — row1 좌 와이드
|
||||
{ span: 'md:col-span-1', size: 'standard' }, // 1 — row1 우 1칸
|
||||
{ span: 'md:col-span-1', size: 'standard' }, // 2 — row2 좌 1칸
|
||||
{ span: 'md:col-span-2', size: 'feature' }, // 3 — row2 우 와이드
|
||||
{ span: 'md:col-span-2', size: 'feature' }, // 4 — row3 좌 와이드
|
||||
{ span: 'md:col-span-1', size: 'standard' }, // 5 — row3 우 1칸
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-3 md:gap-6">
|
||||
{items.map((slot, i) => {
|
||||
const cfg = layout[i] ?? { span: 'md:col-span-1', size: 'standard' as const };
|
||||
return (
|
||||
<ScrollReveal key={slot.slug} delay={i * 80} className={cfg.span}>
|
||||
<ShowcaseCard slot={slot} size={cfg.size} index={i} />
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
app/components/deepfield/useFieldMode.ts
Normal file
29
app/components/deepfield/useFieldMode.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
function detectWebGL(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
|
||||
export function useFieldMode(): FieldMode {
|
||||
const [mode, setMode] = useState<FieldMode>('static');
|
||||
useEffect(() => {
|
||||
setMode(
|
||||
decideFieldMode({
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
webglSupported: detectWebGL(),
|
||||
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
|
||||
viewportWidth: window.innerWidth,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
return mode;
|
||||
}
|
||||
@@ -49,6 +49,14 @@
|
||||
--jsm-accent-hover: #1e40af; /* blue-800 */
|
||||
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
|
||||
|
||||
/* === Deep Field dark tokens (2026-06 랜딩 경험) — 라이트 토큰과 공존 === */
|
||||
--jsm-dark-bg: #070d1a;
|
||||
--jsm-dark-surface: rgba(255, 255, 255, 0.03);
|
||||
--jsm-dark-line: rgba(148, 163, 184, 0.14);
|
||||
--jsm-dark-ink: #f8fafc;
|
||||
--jsm-dark-soft: #94a3b8;
|
||||
--jsm-accent-bright: #60a5fa;
|
||||
|
||||
/* 기존 kx 변수 재매핑 (잔여 참조 호환용) */
|
||||
--kx-surface: var(--jsm-bg);
|
||||
--kx-surface-low: var(--jsm-surface-alt);
|
||||
@@ -190,6 +198,13 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 외주 의뢰 폼 — 다크 스킨 placeholder (Deep Field 재스킨) */
|
||||
.jsm-dark-form input::placeholder,
|
||||
.jsm-dark-form textarea::placeholder {
|
||||
color: var(--jsm-dark-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Service card hover */
|
||||
.service-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -245,6 +260,20 @@ body {
|
||||
mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Deep Field 히어로 스크롤 큐 (가는 세로선 안의 점 미세 바운스) ─── */
|
||||
@keyframes df-scroll-cue {
|
||||
0%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||
50% { transform: translateY(8px); opacity: 1; }
|
||||
}
|
||||
.df-scroll-dot {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.df-scroll-dot {
|
||||
animation: df-scroll-cue 1.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
template: "%s | 쟁승메이드",
|
||||
},
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 설계하고 만듭니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
keywords: [
|
||||
"외주 개발",
|
||||
"소프트웨어 개발",
|
||||
@@ -28,7 +28,7 @@ export const metadata: Metadata = {
|
||||
siteName: "쟁승메이드",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
images: [
|
||||
{
|
||||
url: "https://jaengseung-made.com/og-image.png",
|
||||
@@ -42,7 +42,7 @@ export const metadata: Metadata = {
|
||||
card: "summary_large_image",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 만듭니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공합니다.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -59,19 +59,18 @@ const jsonLd = {
|
||||
'@id': 'https://jaengseung-made.com/#person',
|
||||
name: '박재오',
|
||||
url: 'https://jaengseung-made.com',
|
||||
jobTitle: '백엔드 개발자 · 외주 개발 전문가',
|
||||
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
|
||||
jobTitle: '소프트웨어 엔지니어',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
|
||||
description: '7년차 대기업 백엔드 개발자. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 직접 설계·개발·운영합니다.',
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영합니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공합니다.',
|
||||
},
|
||||
{
|
||||
'@type': 'LocalBusiness',
|
||||
'@id': 'https://jaengseung-made.com/#business',
|
||||
name: '쟁승메이드',
|
||||
url: 'https://jaengseung-made.com',
|
||||
description: '7년차 대기업 백엔드 개발자가 직접 설계·개발·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
priceRange: '₩',
|
||||
@@ -88,7 +87,7 @@ const jsonLd = {
|
||||
'@type': 'Service',
|
||||
name: '외주 개발',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
description: '1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
serviceType: 'Custom Software Development',
|
||||
provider: { '@id': 'https://jaengseung-made.com/#business' },
|
||||
areaServed: '대한민국',
|
||||
|
||||
@@ -2,14 +2,20 @@ import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
|
||||
|
||||
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
|
||||
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
||||
// 메인(/)의 토큰·타이포 패턴(KOR_TIGHT/KOR_BODY)·섹션 리듬과 일관되게 구성한다.
|
||||
import HeroField from '@/app/components/deepfield/HeroField';
|
||||
import ShowcaseGrid from '@/app/components/deepfield/ShowcaseGrid';
|
||||
import ScrollReveal from '@/app/components/deepfield/ScrollReveal';
|
||||
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||
|
||||
// 외주 개발 의뢰 페이지 (서버 컴포넌트) — Deep Field 다크 캔버스.
|
||||
// PublicShell이 TopNav(h-16, /outsourcing 다크 인지)·푸터·main 배경(라이트)을 제공한다.
|
||||
// 이 페이지는 자기 풀-블리드 다크 배경을 소유해 main의 라이트 배경을 덮고,
|
||||
// 메인(/)과 동일한 비주얼 언어(다크 루트 div + -mt-16 hero + 섹션 border-t 리듬 + 모노 라벨 헤더)를 따른다.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '외주 개발',
|
||||
description:
|
||||
'7년차 대기업 백엔드 개발자가 직접 진행하는 맞춤 소프트웨어 외주 개발. 웹 서비스, 업무 자동화, API·백엔드, 봇, AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
|
||||
'24시간 돌아가는 실서비스를 직접 설계·운영하는 손으로, 맞춤 소프트웨어를 만들어 드립니다. 웹 서비스·업무 자동화·API·백엔드·봇·AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
|
||||
};
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
@@ -51,7 +57,7 @@ const PROCESS = [
|
||||
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
|
||||
];
|
||||
|
||||
// 기존 work/freelance(lib/freelance-portfolio) 실사례를 새 토큰 기준으로 재구성.
|
||||
// 기존 work/freelance(lib/freelance-portfolio) 실사례를 다크 토큰 기준으로 재구성.
|
||||
const CASES = [
|
||||
{
|
||||
t: '주식 자동매매 시스템',
|
||||
@@ -97,14 +103,6 @@ const CASES = [
|
||||
},
|
||||
];
|
||||
|
||||
// /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: '견적은 어떻게 산정되나요?',
|
||||
@@ -145,203 +143,163 @@ function ArrowRight() {
|
||||
|
||||
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">
|
||||
// 풀-블리드 다크 캔버스 — main의 라이트 배경을 덮는다.
|
||||
<div style={{ background: 'var(--jsm-dark-bg)', color: 'var(--jsm-dark-ink)' }}>
|
||||
{/* ─────────────────── 1. HERO (축약 ~60vh) ─────────────────── */}
|
||||
{/* -mt-16 pt-16: 고정 헤더 아래로 끌어올려 상단 라이트 띠 제거 */}
|
||||
<section className="relative -mt-16 flex min-h-[60vh] items-center overflow-hidden pt-16">
|
||||
<HeroField className="absolute inset-0" />
|
||||
{/* 콘텐츠 가독성용 스크림 (radial 광원 위 텍스트 대비) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(7,13,26,0.55) 0%, transparent 30%, transparent 62%, rgba(7,13,26,0.78) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-20 lg:px-8 lg:py-24">
|
||||
<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 }}
|
||||
className="mb-7 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
외주 개발
|
||||
<span
|
||||
className="inline-block h-1 w-1 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent-bright)' }}
|
||||
/>
|
||||
outsourcing
|
||||
</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 }}
|
||||
className="font-bold break-keep"
|
||||
style={{
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
fontSize: 'clamp(2.4rem, 7vw, 5rem)',
|
||||
lineHeight: 1.06,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
맞춤 소프트웨어{' '}
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>외주 개발</span>
|
||||
맞춤 소프트웨어
|
||||
<br />
|
||||
외주 개발
|
||||
<span style={{ color: 'var(--jsm-accent-bright)' }}>.</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 }}
|
||||
className="mt-7 max-w-2xl break-keep text-lg leading-relaxed lg:text-xl"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
기획 정리가 안 됐어도 괜찮습니다. 상담에서 함께 정리합니다. 7년차 대기업 백엔드
|
||||
개발자가 기획부터 배포·하자보수까지 단독으로 책임집니다.
|
||||
기획 정리가 안 됐어도 괜찮습니다. 상담에서 함께 정리합니다.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-3">
|
||||
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
|
||||
<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)]"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
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)]"
|
||||
href="#showcase"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-dark-surface)]"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
background: 'var(--jsm-surface)',
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
...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">
|
||||
{/* ─────────────────── 2. SHOWCASE (풀 그리드) ─────────────────── */}
|
||||
<section id="showcase" className="scroll-mt-20 border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
{/* 하위 호환: 기존 /outsourcing#portfolio 링크(메인 footer 등)용 앵커 유지 */}
|
||||
<div id="portfolio" className="scroll-mt-20" />
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Scope
|
||||
showcase
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
이런 것들을 만들어 드립니다
|
||||
우리가 만드는 화면들
|
||||
</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>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14">
|
||||
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />
|
||||
</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">
|
||||
{/* ─────────────────── 3. 운영 실사례 ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Process
|
||||
in production
|
||||
</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 }}
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
직접 개발하고, 실제로 굴러가는 결과물
|
||||
</h2>
|
||||
<p
|
||||
className="mt-4 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
className="mt-4 max-w-xl break-keep leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
운영 중인 서비스와 납품 완료 프로젝트입니다. 의뢰하신 프로젝트도 같은 깊이로 만듭니다.
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* 실사례 카드 */}
|
||||
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{CASES.map((c) => (
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CASES.map((c, i) => (
|
||||
<ScrollReveal key={c.t} delay={i * 80}>
|
||||
<div
|
||||
key={c.t}
|
||||
className="flex flex-col rounded-2xl p-7 border"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
className="flex h-full flex-col rounded-2xl border p-7"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-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"
|
||||
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
style={
|
||||
c.live
|
||||
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
|
||||
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
|
||||
? { color: 'var(--jsm-accent-bright)', background: 'rgba(96,165,250,0.12)' }
|
||||
: { color: 'var(--jsm-dark-soft)', background: 'rgba(148,163,184,0.08)' }
|
||||
}
|
||||
>
|
||||
{c.live && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent-bright)' }}
|
||||
/>
|
||||
)}
|
||||
{c.cat}
|
||||
</span>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-dark-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 }}
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{c.d}
|
||||
</p>
|
||||
@@ -349,10 +307,10 @@ export default function OutsourcingPage() {
|
||||
{c.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2.5 py-1 rounded"
|
||||
className="rounded px-2.5 py-1 text-xs"
|
||||
style={{
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
color: 'var(--jsm-dark-soft)',
|
||||
background: 'rgba(148,163,184,0.08)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -361,90 +319,147 @@ export default function OutsourcingPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</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">
|
||||
{/* ─────────────────── 4a. 제공 분야 ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
FAQ
|
||||
scope
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
이런 것들을 만들어 드립니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FIELDS.map((f, i) => (
|
||||
<ScrollReveal key={f.t} delay={i * 80}>
|
||||
<div
|
||||
className="h-full rounded-2xl border p-7"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{f.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{f.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 4b. 진행 프로세스 ─────────────────── */}
|
||||
<section id="process" className="scroll-mt-20 border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
process
|
||||
</p>
|
||||
<h2
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
상담부터 하자보수까지, 흐름이 분명합니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PROCESS.map((s, i) => (
|
||||
<ScrollReveal key={s.n} delay={i * 80}>
|
||||
<div
|
||||
className="relative h-full rounded-2xl border p-7 lg:p-8"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="relative z-10 inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
|
||||
style={{
|
||||
color: 'var(--jsm-accent-bright)',
|
||||
background: 'var(--jsm-dark-bg)',
|
||||
boxShadow: 'inset 0 0 0 1px var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3
|
||||
className="mt-5 break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 5. FAQ ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-3xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
faq
|
||||
</p>
|
||||
<h2
|
||||
className="break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{FAQ.map((item) => (
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 space-y-3">
|
||||
{FAQ.map((item, i) => (
|
||||
<ScrollReveal key={item.q} delay={i * 80}>
|
||||
<details
|
||||
key={item.q}
|
||||
className="group rounded-2xl border overflow-hidden"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
className="group overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-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 }}
|
||||
className="flex cursor-pointer list-none items-center justify-between gap-4 break-keep px-6 py-5 font-semibold"
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{item.q}
|
||||
<svg
|
||||
@@ -457,83 +472,101 @@ export default function OutsourcingPage() {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
aria-hidden
|
||||
style={{ color: 'var(--jsm-ink-faint)' }}
|
||||
style={{ color: 'var(--jsm-dark-soft)' }}
|
||||
>
|
||||
<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 }}
|
||||
className="break-keep px-6 pb-5 text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{item.a}
|
||||
</p>
|
||||
</details>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</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">
|
||||
{/* ─────────────────── 6. 의뢰 폼 ─────────────────── */}
|
||||
<section id="contact" className="scroll-mt-20 border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<div className="grid gap-10 lg:grid-cols-5 lg:gap-12">
|
||||
{/* 안내 */}
|
||||
<div className="lg:col-span-2">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: '#7aa7ff' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Contact
|
||||
contact
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
|
||||
style={KOR_TIGHT}
|
||||
className="break-keep text-3xl font-bold leading-tight lg:text-[2.5rem]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
프로젝트 문의
|
||||
</h2>
|
||||
<p
|
||||
className="mt-5 text-lg leading-relaxed text-white/70 break-keep"
|
||||
style={KOR_BODY}
|
||||
className="mt-5 break-keep text-lg leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
영업일 2일 내에 회신드립니다. 아이디어 단계여도 괜찮습니다 — 상담에서 방향을
|
||||
함께 잡아드립니다.
|
||||
</p>
|
||||
<div
|
||||
className="mt-8 pt-8 border-t space-y-3"
|
||||
style={{ borderColor: 'rgba(255,255,255,0.12)' }}
|
||||
className="mt-8 space-y-3 border-t pt-8"
|
||||
style={{ borderColor: 'var(--jsm-dark-line)' }}
|
||||
>
|
||||
<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}
|
||||
className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-dark-ink)]"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Mail</span>
|
||||
<span
|
||||
className="w-12 font-mono text-xs uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
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}
|
||||
className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-dark-ink)]"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Tel</span>
|
||||
<span
|
||||
className="w-12 font-mono text-xs uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Tel
|
||||
</span>
|
||||
010-3907-1392
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<div className="lg:col-span-3">
|
||||
<ScrollReveal delay={100}>
|
||||
<div
|
||||
className="rounded-2xl p-6 lg:p-8"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
className="rounded-2xl border p-6 lg:p-8"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
<OutsourcingRequestForm />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
538
app/page.tsx
538
app/page.tsx
@@ -2,8 +2,16 @@ import Link from 'next/link';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
|
||||
|
||||
// 쟁승메이드 메인 — 외주 개발 + 완성 소프트웨어 2축 랜딩 (서버 컴포넌트)
|
||||
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
||||
import HeroField from './components/deepfield/HeroField';
|
||||
import ShowcaseGrid from './components/deepfield/ShowcaseGrid';
|
||||
import ScrollReveal from './components/deepfield/ScrollReveal';
|
||||
import CountUp from './components/deepfield/CountUp';
|
||||
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||
|
||||
// 쟁승메이드 메인 — Deep Field 다크 캔버스 (서버 컴포넌트)
|
||||
// PublicShell이 TopNav(h-16, 다크 인지)·푸터(navy)·main 배경(라이트)을 제공한다.
|
||||
// 이 페이지는 자기 풀-블리드 다크 배경을 소유하여 main의 라이트 배경을 덮는다.
|
||||
// 히어로를 -mt-16 + pt-16으로 끌어올려 pt-16로 인한 상단 16px 라이트 띠를 제거한다.
|
||||
|
||||
// 소프트웨어 진열 섹션이 DB 조회를 포함하므로 항상 최신 목록을 보여준다.
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -18,15 +26,7 @@ const PROCESS = [
|
||||
{ n: '04', t: '납품·배포 지원', d: '검수 후 30일 무상 하자보수로 안정화까지 책임집니다.' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ v: '7년차', l: '대기업 백엔드 개발 경력' },
|
||||
{ v: '15+', l: '직접 운영 중인 서비스' },
|
||||
{ v: '기획→배포', l: '원스톱 단독 진행' },
|
||||
];
|
||||
|
||||
const STACK = ['Python', 'Java', 'Spring', 'Next.js', 'AI 연동'];
|
||||
|
||||
const PORTFOLIO = [
|
||||
const PROOF = [
|
||||
{
|
||||
t: '주식 자동매매 시스템',
|
||||
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
|
||||
@@ -78,52 +78,69 @@ export default async function Home() {
|
||||
const hasProducts = featuredProducts.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── 1. Hero ─── */}
|
||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
|
||||
<div className="max-w-3xl">
|
||||
<span
|
||||
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
|
||||
// 풀-블리드 다크 캔버스 — main의 라이트 배경을 덮는다.
|
||||
<div style={{ background: 'var(--jsm-dark-bg)', color: 'var(--jsm-dark-ink)' }}>
|
||||
{/* ─────────────────── 1. HERO ─────────────────── */}
|
||||
{/* -mt-16 pt-16: 고정 헤더 아래로 끌어올려 상단 라이트 띠 제거 + 풀 뷰포트 확보 */}
|
||||
<section className="relative -mt-16 flex min-h-[100svh] items-center overflow-hidden">
|
||||
<HeroField className="absolute inset-0" />
|
||||
{/* 콘텐츠 가독성용 하단 스크림 (radial 광원 위 텍스트 대비) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
color: 'var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
...KOR_BODY,
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(7,13,26,0.55) 0%, transparent 28%, transparent 60%, rgba(7,13,26,0.75) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 pt-28 pb-24 lg:px-8 lg:pt-32">
|
||||
<div className="max-w-4xl">
|
||||
<span
|
||||
className="mb-7 inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-1 w-1 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent-bright)' }}
|
||||
/>
|
||||
외주 개발 · 완성 소프트웨어
|
||||
</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 }}
|
||||
className="font-bold break-keep"
|
||||
style={{
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
fontSize: 'clamp(2.6rem, 8vw, 5.75rem)',
|
||||
lineHeight: 1.04,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
필요한 소프트웨어,
|
||||
<br className="hidden sm:block" /> 만들어 드리거나{' '}
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>이미 만들어 두었습니다.</span>
|
||||
생각을
|
||||
<br />
|
||||
동작하는 소프트웨어로
|
||||
<span style={{ color: 'var(--jsm-accent-bright)' }}>.</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 }}
|
||||
className="mt-8 max-w-2xl break-keep text-lg leading-relaxed lg:text-xl"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과
|
||||
검증된 완성 소프트웨어 중 필요한 쪽을 선택하세요.
|
||||
24시간 돌아가는 실서비스를 직접 설계하고 운영합니다. 외주 개발도, 완성
|
||||
소프트웨어도 — 같은 손으로.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-3">
|
||||
<div className="mt-11 flex flex-col gap-3 sm:flex-row">
|
||||
<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"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
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)]"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-dark-surface)]"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
background: 'var(--jsm-surface)',
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -132,215 +149,190 @@ export default async function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 큐 — 가는 세로선 + 점 미세 바운스 (motion-safe 가드는 CSS) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-7 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="block h-9 w-px"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, transparent, var(--jsm-dark-line))',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="df-scroll-dot block h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent-bright)' }}
|
||||
/>
|
||||
</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>
|
||||
{/* ─────────────────── 2. SHOWCASE ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="mt-3 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
기획부터 배포·운영까지 한 사람이 책임집니다. 웹 서비스, API, 업무 자동화,
|
||||
봇 개발까지 필요한 형태로 만들어 드립니다.
|
||||
showcase
|
||||
</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 }}
|
||||
<h2
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
외주 개발 알아보기
|
||||
<ArrowRight />
|
||||
</span>
|
||||
</Link>
|
||||
이런 걸 만들어 드립니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* 완성 소프트웨어 */}
|
||||
<div className="mt-14">
|
||||
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="home" />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex justify-end">
|
||||
<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)' }}
|
||||
href="/outsourcing#showcase"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:opacity-80"
|
||||
style={{ color: 'var(--jsm-accent-bright)', ...KOR_BODY }}
|
||||
>
|
||||
<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">
|
||||
{/* ─────────────────── 3. PROCESS ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Process
|
||||
process
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
상담부터 납품까지, 흐름이 분명합니다
|
||||
</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)' }}>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="relative mt-14">
|
||||
{/* 단계 연결선 — draw 라인 (데스크톱 가로 관통) */}
|
||||
<ScrollReveal
|
||||
variant="draw"
|
||||
className="absolute left-0 right-0 top-7 hidden lg:block"
|
||||
>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
|
||||
className="block h-px w-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to right, transparent, var(--jsm-dark-line) 12%, var(--jsm-dark-line) 88%, transparent)',
|
||||
}}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid gap-px sm:grid-cols-2 lg:grid-cols-4 lg:gap-6 lg:bg-transparent">
|
||||
{PROCESS.map((s, i) => (
|
||||
<ScrollReveal key={s.n} delay={i * 100}>
|
||||
<div
|
||||
className="relative h-full rounded-2xl border p-7 lg:p-8"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="relative z-10 inline-flex h-14 w-14 items-center justify-center rounded-full font-mono text-sm font-bold"
|
||||
style={{
|
||||
color: 'var(--jsm-accent-bright)',
|
||||
background: 'var(--jsm-dark-bg)',
|
||||
boxShadow: 'inset 0 0 0 1px var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3
|
||||
className="mt-4 text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="mt-5 break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-dark-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
className="mt-2 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</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}>
|
||||
{/* ─────────────────── 4. PROOF ─────────────────── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
<ScrollReveal>
|
||||
<p
|
||||
className="text-3xl lg:text-4xl font-bold text-white"
|
||||
style={KOR_TIGHT}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
{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
|
||||
in production
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
실제로 운영 중인 시스템들
|
||||
데모가 아니라 매일 돌아가는 시스템
|
||||
</h2>
|
||||
<p
|
||||
className="mt-4 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
className="mt-4 max-w-xl break-keep leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
데모가 아니라 매일 돌아가는 서비스입니다. 같은 깊이로 의뢰하신 프로젝트를 만듭니다.
|
||||
직접 개발하고 운영 중인 실서비스입니다. 같은 깊이로 의뢰하신 프로젝트를 만듭니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-12 grid md:grid-cols-3 gap-6">
|
||||
{PORTFOLIO.map((p) => (
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
||||
{PROOF.map((p, i) => (
|
||||
<ScrollReveal key={p.t} delay={i * 100}>
|
||||
<div
|
||||
key={p.t}
|
||||
className="flex flex-col rounded-2xl p-7 border"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
className="flex h-full flex-col rounded-2xl border p-7"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-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)' }}
|
||||
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
style={{
|
||||
color: 'var(--jsm-accent-bright)',
|
||||
background: 'rgba(96,165,250,0.12)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent-bright)' }}
|
||||
/>
|
||||
직접 개발·운영 중
|
||||
</span>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-dark-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 }}
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.d}
|
||||
</p>
|
||||
@@ -348,10 +340,10 @@ export default async function Home() {
|
||||
{p.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2.5 py-1 rounded"
|
||||
className="rounded px-2.5 py-1 text-xs"
|
||||
style={{
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
color: 'var(--jsm-dark-soft)',
|
||||
background: 'rgba(148,163,184,0.08)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -360,75 +352,121 @@ export default async function Home() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</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 }}
|
||||
|
||||
{/* 스탯 3종 — 카운트업 */}
|
||||
<ScrollReveal className="mt-14">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border sm:grid-cols-3"
|
||||
style={{ borderColor: 'var(--jsm-dark-line)', background: 'var(--jsm-dark-line)' }}
|
||||
>
|
||||
포트폴리오 자세히 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-dark-bg)' }}>
|
||||
<p
|
||||
className="text-4xl font-bold lg:text-5xl"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
<CountUp to={15} suffix="+" />
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 break-keep text-sm"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
직접 운영 중인 실서비스
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-dark-bg)' }}>
|
||||
<p
|
||||
className="text-4xl font-bold lg:text-5xl"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
24/7
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 break-keep text-sm"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
무중단 운영
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-dark-bg)' }}>
|
||||
<p
|
||||
className="text-4xl font-bold lg:text-5xl"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
원스톱
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 break-keep text-sm"
|
||||
style={{ color: 'var(--jsm-dark-soft)', ...KOR_BODY }}
|
||||
>
|
||||
기획 → 배포 단독 진행
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 6. 소프트웨어 진열 ─── */}
|
||||
{/* ─────────────────── 5. SOFTWARE + CTA ─────────────────── */}
|
||||
{/* Phase 2: products 테이블 기반 동적 진열. 0개이면 출시 준비 중 폴백. */}
|
||||
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-dark-line)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-24 lg:px-8 lg:py-32">
|
||||
{hasProducts ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between mb-10">
|
||||
<ScrollReveal>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Software
|
||||
software
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="break-keep text-3xl font-bold lg:text-[2.75rem] lg:leading-[1.12]"
|
||||
style={{ color: 'var(--jsm-dark-ink)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
완성 소프트웨어
|
||||
바로 쓰는 완성 소프트웨어
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/products"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] shrink-0"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
className="hidden shrink-0 items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:opacity-80 sm:inline-flex"
|
||||
style={{ color: 'var(--jsm-accent-bright)', ...KOR_BODY }}
|
||||
>
|
||||
전체 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{featuredProducts.map((p) => (
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
||||
{featuredProducts.map((p, i) => (
|
||||
<ScrollReveal key={p.id} delay={i * 100}>
|
||||
{/* 라이트 카드가 다크 위에 떠 있는 대비 */}
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/products/${p.id}`}
|
||||
className="group flex flex-col rounded-2xl p-7 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
className="group flex h-full flex-col rounded-2xl p-7 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.6)] transition-transform duration-300 hover:translate-y-[-2px]"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{p.name}
|
||||
</h3>
|
||||
{p.description && (
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="mt-6 pt-5 flex items-center justify-between border-t"
|
||||
className="mt-6 flex items-center justify-between border-t pt-5"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
@@ -446,13 +484,14 @@ export default async function Home() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 sm:hidden">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-accent-bright)', ...KOR_BODY }}
|
||||
>
|
||||
전체 보기
|
||||
<ArrowRight />
|
||||
@@ -460,35 +499,39 @@ export default async function Home() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ScrollReveal>
|
||||
<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)' }}
|
||||
className="rounded-2xl border px-8 py-14 text-center lg:px-14 lg:py-16"
|
||||
style={{
|
||||
background: 'var(--jsm-dark-surface)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]"
|
||||
style={{ color: 'var(--jsm-accent-bright)' }}
|
||||
>
|
||||
Coming soon
|
||||
coming soon
|
||||
</p>
|
||||
<h2
|
||||
className="text-2xl lg:text-3xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="break-keep text-2xl font-bold lg:text-3xl"
|
||||
style={{ color: 'var(--jsm-dark-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 }}
|
||||
className="mx-auto mt-4 max-w-xl break-keep leading-relaxed"
|
||||
style={{ color: 'var(--jsm-dark-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)]"
|
||||
className="mt-8 inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-dark-surface)]"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
color: 'var(--jsm-dark-ink)',
|
||||
borderColor: 'var(--jsm-dark-line)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -496,37 +539,50 @@ export default async function Home() {
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</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">
|
||||
{/* 최종 CTA 밴드 — accent bg */}
|
||||
<ScrollReveal className="mt-24 lg:mt-32">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl px-8 py-16 lg:px-16 lg:py-20"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{/* 광원 — radial 허용 */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(60% 80% at 85% 0%, rgba(96,165,250,0.45) 0%, transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative max-w-3xl">
|
||||
<h2
|
||||
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
|
||||
className="break-keep text-3xl font-bold leading-tight text-white lg:text-[2.5rem]"
|
||||
style={KOR_TIGHT}
|
||||
>
|
||||
프로젝트, 이야기부터 시작하세요
|
||||
</h2>
|
||||
<p
|
||||
className="mt-5 text-lg leading-relaxed text-white/70 break-keep max-w-2xl"
|
||||
className="mt-5 max-w-2xl break-keep text-lg leading-relaxed text-white/80"
|
||||
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 }}
|
||||
className="mt-9 inline-flex items-center justify-center gap-2 rounded-lg bg-white px-7 py-4 font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
무료 상담 신청
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
384
docs/superpowers/plans/2026-06-12-deepfield-landing.md
Normal file
384
docs/superpowers/plans/2026-06-12-deepfield-landing.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Deep Field 랜딩 경험 구현 계획
|
||||
|
||||
> **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.
|
||||
> **비주얼 태스크(4·5·7·8)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.**
|
||||
|
||||
**Goal:** 메인(/)·/outsourcing을 "Deep Field" 다크 캔버스로 재구성 — WebGL 커서 반응 히어로 + 몰입형 쇼케이스(주인공) + 스크롤 연출, 3단계 성능 폴백 내장.
|
||||
|
||||
**Architecture:** 다크 토큰 6종을 기존 jsm 체계에 추가(라이트 토큰 무수정). WebGL은 `app/components/deepfield/`에 격리된 클라이언트 경계 — 페이지는 서버 컴포넌트 유지, three.js는 dynamic import. 모드 판정(`full|lite|static`)은 순수 함수(`lib/deepfield-mode.ts`)로 TDD. 쇼케이스 데이터는 `lib/showcase.ts` 단일 소스(8슬롯, href 있는 슬롯만 클릭 가능).
|
||||
|
||||
**Tech Stack:** Next.js 16, three.js(코어만, dynamic import), Tailwind v4, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-12-deep-field-landing-design.md`
|
||||
**Branch:** `feature/deepfield-landing`
|
||||
|
||||
---
|
||||
|
||||
## 카피 절대 규칙 (전 태스크 공통)
|
||||
|
||||
"7년차", "대기업" 등 경력·소속 표현 **금지** — 신규 카피·metadata·jsonLd 전부. 신뢰 축은 "24시간 돌아가는 실서비스 15+를 직접 설계·운영" ([[feedback-copy-no-career-emphasis]]).
|
||||
|
||||
## 무수정 금지선 (전 태스크 공통)
|
||||
|
||||
OutsourcingRequestForm 로직·검증·API / products 동적 연동 로직(`loadFeaturedProducts`) / 라우팅·redirect / 거래·계정·admin 페이지 / TopNav auth 로직.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 기반 — three 설치 + 다크 토큰 + 쇼케이스 데이터
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (`npm install three @types/three`)
|
||||
- Modify: `app/globals.css` (다크 토큰 6종 추가 — 기존 토큰 무수정)
|
||||
- Create: `lib/showcase.ts`
|
||||
|
||||
- [ ] **Step 1:** `npm install three` + `npm install -D @types/three`
|
||||
- [ ] **Step 2:** `app/globals.css`의 `:root`에 추가 (기존 jsm 라이트 토큰 아래):
|
||||
|
||||
```css
|
||||
/* === Deep Field dark tokens (2026-06 랜딩 경험) — 라이트 토큰과 공존 === */
|
||||
--jsm-dark-bg: #070d1a;
|
||||
--jsm-dark-surface: rgba(255, 255, 255, 0.03);
|
||||
--jsm-dark-line: rgba(148, 163, 184, 0.14);
|
||||
--jsm-dark-ink: #f8fafc;
|
||||
--jsm-dark-soft: #94a3b8;
|
||||
--jsm-accent-bright: #60a5fa;
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** `lib/showcase.ts`:
|
||||
|
||||
```typescript
|
||||
/** Deep Field 쇼케이스 8슬롯 — 단일 소스.
|
||||
* href가 있는 슬롯만 클릭 가능 (샘플 리뉴얼 완료 시 href 추가). */
|
||||
export interface ShowcaseSlot {
|
||||
slug: string;
|
||||
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
||||
title: string; // 카드 타이틀 (한글)
|
||||
desc: string; // 한 줄 설명
|
||||
palette: [string, string]; // 카드 고유 그래디언트 월드 [from, to]
|
||||
accent: string; // 카드 포인트 컬러
|
||||
href?: string; // 리뉴얼 완료된 샘플의 데모 링크
|
||||
}
|
||||
|
||||
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
||||
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 IR까지', palette: ['#13203a', '#0d2c54'], accent: '#60a5fa' },
|
||||
{ slug: 'shopping', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', palette: ['#1a1430', '#341a4f'], accent: '#c4b5fd' },
|
||||
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', palette: ['#0f2922', '#14503c'], accent: '#6ee7b7' },
|
||||
{ slug: 'bakery', label: 'local shop', title: '로컬 매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', palette: ['#2b1a10', '#4f2d14'], accent: '#fdba74' },
|
||||
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', palette: ['#101418', '#23272d'], accent: '#e2e8f0' },
|
||||
{ slug: 'game', label: 'game', title: '게임 프로모션', desc: '세계관에 빠져들게 하는 런칭 페이지', palette: ['#250f23', '#4a1342'], accent: '#f0abfc' },
|
||||
{ slug: 'interior', label: 'interior', title: '인테리어 스튜디오', desc: '공간의 톤을 그대로 옮긴 쇼룸', palette: ['#1f2218', '#3a4028'], accent: '#d9f99d' },
|
||||
{ slug: 'reading', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', palette: ['#101b2b', '#1f3a5f'], accent: '#93c5fd' },
|
||||
];
|
||||
```
|
||||
|
||||
(컨셉·팔레트는 기존 샘플 8종의 주제를 승계 — 각 샘플 page.tsx를 열어 주제가 맞는지 확인하고 어긋나면 title/desc만 조정)
|
||||
|
||||
- [ ] **Step 4:** `npm test`(10) + `npm run build` 통과
|
||||
- [ ] **Step 5:** Commit — `feat(deepfield): three.js + 다크 토큰 + 쇼케이스 8슬롯 데이터`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 모드 판정 (TDD) + WebGL 지원 훅
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/deepfield-mode.ts`
|
||||
- Test: `lib/__tests__/deepfield-mode.test.ts`
|
||||
- Create: `app/components/deepfield/useFieldMode.ts`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `lib/__tests__/deepfield-mode.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideFieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
|
||||
|
||||
describe('decideFieldMode', () => {
|
||||
it('데스크톱 + WebGL = full', () => {
|
||||
expect(decideFieldMode(base)).toBe('full');
|
||||
});
|
||||
it('reduced-motion이면 무조건 static', () => {
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
|
||||
});
|
||||
it('WebGL 미지원이면 static', () => {
|
||||
expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
|
||||
});
|
||||
it('모바일 뷰포트(<768)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
|
||||
});
|
||||
it('저성능 코어(<4)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
|
||||
});
|
||||
it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `npm test` → FAIL 확인
|
||||
- [ ] **Step 3: 구현** — `lib/deepfield-mode.ts`:
|
||||
|
||||
```typescript
|
||||
export type FieldMode = 'full' | 'lite' | 'static';
|
||||
|
||||
export interface FieldEnv {
|
||||
reducedMotion: boolean;
|
||||
webglSupported: boolean;
|
||||
hardwareConcurrency: number; // 미보고 시 0
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
|
||||
export function decideFieldMode(env: FieldEnv): FieldMode {
|
||||
if (env.reducedMotion) return 'static';
|
||||
if (!env.webglSupported) return 'static';
|
||||
if (env.viewportWidth < 768) return 'lite';
|
||||
if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
|
||||
return 'full';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** `npm test` → 16 passed (기존 10 + 신규 6)
|
||||
- [ ] **Step 5: 훅** — `app/components/deepfield/useFieldMode.ts` ('use client'):
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
function detectWebGL(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
|
||||
export function useFieldMode(): FieldMode {
|
||||
const [mode, setMode] = useState<FieldMode>('static');
|
||||
useEffect(() => {
|
||||
setMode(
|
||||
decideFieldMode({
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
webglSupported: detectWebGL(),
|
||||
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
|
||||
viewportWidth: window.innerWidth,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
return mode;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6:** `npm run build` 통과 → Commit — `feat(deepfield): 렌더 모드 판정(TDD) + useFieldMode 훅`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ScrollReveal` 공용 연출 컴포넌트
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/ScrollReveal.tsx`
|
||||
|
||||
- [ ] **Step 1:** 'use client' 컴포넌트 — IntersectionObserver 기반:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** 등장 지연(ms) — 연속 항목 스태거용 */
|
||||
delay?: number;
|
||||
/** 'fade-up'(기본) | 'fade' | 'draw'(선 그리기용 — width 확장) */
|
||||
variant?: 'fade-up' | 'fade' | 'draw';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScrollReveal({ children, delay = 0, variant = 'fade-up', className }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// reduced-motion: 즉시 표시 (연출 생략)
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setShown(true);
|
||||
return;
|
||||
}
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setShown(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
const hidden =
|
||||
variant === 'fade' ? 'opacity-0' :
|
||||
variant === 'draw' ? 'opacity-0 [transform:scaleX(0)] origin-left' :
|
||||
'opacity-0 translate-y-6';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? 'opacity-100 translate-y-0 [transform:none]' : hidden}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `npm run build` 통과 → Commit — `feat(deepfield): ScrollReveal 스크롤 연출 컴포넌트`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `HeroField` — WebGL 커서 반응 파티클 필드
|
||||
|
||||
> **designer + soft-skill 로드 필수.** 가장 중요한 비주얼 태스크.
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/HeroField.tsx`
|
||||
|
||||
**요구 동작:**
|
||||
- props: `{ className?: string }` — 히어로 섹션의 절대배치 배경 캔버스
|
||||
- `useFieldMode()`로 모드 결정:
|
||||
- **static**: 캔버스 미기동 — `--jsm-dark-bg` 위 정적 radial 그래디언트(accent 30~40% 불투명 2개 광원) div 렌더. 이것만으로도 완성된 비주얼이어야 함
|
||||
- **lite**: 파티클 수 full의 1/4, 커서 반응 비활성(자동 드리프트만), DPR 1 고정
|
||||
- **full**: 파티클 포인트 필드(2,000~4,000pt) — 커서 위치를 향해 자기장처럼 휘는 변위(셰이더 uniform으로 마우스 전달), 미세 드리프트, 스크롤 진행도(uniform)에 따라 필드가 흩어짐
|
||||
- **three.js는 `await import('three')`로 dynamic import** — 모듈 상단 정적 import 금지
|
||||
- 색: 파티클은 `#60a5fa`~`#1d4ed8` 범위, 배경은 투명(섹션 bg가 비침)
|
||||
- 정리: 언마운트 시 renderer.dispose()+geometry/material dispose, `document.visibilityState` hidden 시 rAF 정지, IntersectionObserver로 화면 밖이면 정지
|
||||
- 마우스 추적은 window 리스너(passive), rAF 내에서 lerp로 부드럽게
|
||||
- 캔버스에 `aria-hidden="true"`, pointer-events 없음
|
||||
|
||||
- [ ] **Step 1:** 컴포넌트 구현 (위 3모드)
|
||||
- [ ] **Step 2:** `npm run build` 통과 + 임시 검증: dev 서버에서 컴포넌트를 임시로 메인에 올리지 말고, Task 6에서 통합 검증 (이 태스크는 build·타입 통과까지)
|
||||
- [ ] **Step 3:** Commit — `feat(deepfield): HeroField WebGL 파티클 필드 (full/lite/static)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `ShowcaseGrid` + `ShowcaseCard`
|
||||
|
||||
> **designer + soft-skill 로드 필수.**
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/ShowcaseCard.tsx`
|
||||
- Create: `app/components/deepfield/ShowcaseGrid.tsx`
|
||||
|
||||
**ShowcaseCard** — props `{ slot: ShowcaseSlot, size?: 'feature' | 'standard', index: number }`:
|
||||
- 카드 비주얼 = 슬롯 palette 그래디언트 월드 + 절제된 제너러티브 패턴(슬롯별로 달라 보이게 — slug를 시드로 한 캔버스 2D 패턴: 격자/등고선/도트 등 2~3종 변형). WebGL 필수 아님 — **카드 타일은 Canvas2D로 충분** (성능·단순성). hover 시:
|
||||
- full 모드: 타일이 미세 굴절(translate+scale 1.03)되고 패턴이 커서 방향으로 시차 이동 (CSS transform + mousemove 기반 — 카드당 WebGL 인스턴스 금지)
|
||||
- lite/static: CSS 전환만 (border accent 점등 + 살짝 lift)
|
||||
- 텍스트: 모노스페이스 label(accent 컬러) + 한글 title(굵게) + desc 1줄
|
||||
- `slot.href` 있으면 `<Link>` 래핑 + "데모 보기 →" 표시 / 없으면 비클릭(커서 default, hover는 동일하게 동작 — "준비 중" 라벨 금지)
|
||||
- `aria-label` = title
|
||||
|
||||
**ShowcaseGrid** — props `{ slots: ShowcaseSlot[], variant: 'home' | 'full' }`:
|
||||
- `home`: 상위 6슬롯, 비대칭 그리드 — 1번 feature(2col), 2·3 standard, 4 feature, 5·6 standard (데스크톱 3col 기준 / 모바일 1col 스택). 각 카드는 `ScrollReveal`로 스태거 등장(delay = index*80)
|
||||
- `full`: 8슬롯 전체, 2col 균등(모바일 1col)
|
||||
- 서버에서 import 가능하도록 그리드 자체는 서버 컴포넌트, 카드만 'use client'
|
||||
|
||||
- [ ] **Step 1:** ShowcaseCard 구현 (Canvas2D 패턴 + hover)
|
||||
- [ ] **Step 2:** ShowcaseGrid 구현
|
||||
- [ ] **Step 3:** `npm run build` 통과 → Commit — `feat(deepfield): 쇼케이스 카드·그리드 (제너러티브 타일 + 호버 시차)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: TopNav route-aware 다크 모드
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx`
|
||||
|
||||
- [ ] **Step 1:** `usePathname()`으로 다크 페이지 판정:
|
||||
|
||||
```typescript
|
||||
const DARK_ROUTES = ['/', '/outsourcing'];
|
||||
const isDark = DARK_ROUTES.includes(pathname) || pathname.startsWith('/outsourcing/');
|
||||
```
|
||||
|
||||
- 다크 페이지: 기본 투명 배경 + `--jsm-dark-ink` 텍스트, 스크롤 시 `rgba(7,13,26,0.85)` 배경 + `--jsm-dark-line` 하단 보더. 로고·링크·CTA 색상도 다크 팔레트(accent-bright 활성)
|
||||
- 라이트 페이지: **기존 동작 그대로** (흰 배경 전환)
|
||||
- 모바일 드로어: 다크 페이지에서는 다크 패널(`--jsm-dark-bg`), 라이트에서는 기존 흰 패널
|
||||
- **auth 로직(getSession/onAuthStateChange/handleLogout)·접근성 속성(aria-expanded/Esc/dialog) 무수정**
|
||||
|
||||
- [ ] **Step 2:** `npm run build` + dev에서 `/products`(라이트)·`/`(다크 예정 — 아직 페이지는 라이트지만 네비만 다크 톤이 되는 과도기 OK, Task 7과 같은 PR이므로 순서상 문제 없음) 컴파일 확인
|
||||
- [ ] **Step 3:** Commit — `feat(nav): 다크 라우트 인지형 네비게이션`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 메인(/) Deep Field 재조립 + 카피·메타 교체
|
||||
|
||||
> **designer + soft-skill 로드 필수.** 스펙 §2의 5섹션 구조.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/page.tsx` (전면 재구성 — products 동적 로직 `loadFeaturedProducts`는 그대로 이식)
|
||||
- Modify: `app/layout.tsx` (metadata description·jsonLd에서 경력 표현 제거 — 구조 무수정)
|
||||
- Modify: `app/components/PublicShell.tsx` (main 배경이 페이지별로 다크/라이트 — main의 고정 `--jsm-bg` 인라인 배경을 제거하고 페이지가 자기 배경을 그리도록, 또는 route-aware. 푸터·KakaoFloatButton 무수정)
|
||||
|
||||
**섹션 구성 (승인된 목업 기준):**
|
||||
1. **HERO** — min-h-[100svh] 풀스크린. `HeroField` 배경 + 거대 타이포: "생각을\n동작하는 소프트웨어로." (디자인 스킬로 다듬기 허용 — 단 경력 표현 금지). 서브 1줄: "24시간 돌아가는 실서비스를 직접 설계하고 운영합니다. 외주 개발도, 완성 소프트웨어도 — 같은 손으로." CTA 2개([프로젝트 문의 → /outsourcing#contact] accent 솔리드 / [소프트웨어 보기 → /products] 다크 고스트). 하단 스크롤 큐(미세 바운스 화살표)
|
||||
2. **SHOWCASE** — "이런 걸 만들어 드립니다" + `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="home" />` + [전체 레퍼런스 → /outsourcing#showcase]
|
||||
3. **PROCESS** — 4단계(기존 카피 유지: 상담→견적 2일→주1회 공유→납품+30일 하자보수), ScrollReveal `draw`로 연결선 + 스태거 점등
|
||||
4. **PROOF** — 운영 시스템 3종 카드(주식 자동매매/청약 자동 매칭/AI 콘텐츠 파이프라인 — 기존 카피 재사용 가능) + 스탯: "실서비스 15+" "24/7 무중단 운영" "기획→배포 원스톱" (스크롤 진입 시 카운트업은 ScrollReveal + 간단한 useEffect 카운터, reduced-motion 시 즉시 최종값)
|
||||
5. **SOFTWARE + CTA** — `loadFeaturedProducts` 동적 연동 그대로(라이트 카드가 다크 위에 뜨는 대비), 빈 상태 폴백 유지. 최종 CTA 밴드(accent)
|
||||
|
||||
- metadata: title 유지, description → "24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어." / jsonLd Person·LocalBusiness description에서 "7년차" 제거, jobTitle "소프트웨어 엔지니어"로
|
||||
- 전체 페이지 배경 `--jsm-dark-bg`, 텍스트 다크 토큰. 가드레일: gradient는 **Deep Field 광원 표현에 한해 radial 그래디언트 허용**(다크 캔버스의 일부 — 기존 "그래디언트 금지"의 의도는 generic AI 보라 그라데이션 차단이었음), 보라 금지 유지(쇼케이스 palette의 컨셉 컬러는 예외 — 카드 월드 한정), blur 금지, 이모지 금지
|
||||
|
||||
- [ ] **Step 1:** 페이지 재조립 + 카피 교체
|
||||
- [ ] **Step 2:** `npm run build` + dev: `/` 200, "7년차"·"대기업" grep 0건(app/page.tsx·app/layout.tsx), products 폴백 동작
|
||||
- [ ] **Step 3:** Commit — `feat(home): Deep Field 다크 캔버스 재조립 + 운영 실증 카피`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: /outsourcing Deep Field 재스킨
|
||||
|
||||
> **designer + soft-skill 로드 필수.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/outsourcing/page.tsx`
|
||||
- Modify(스타일만): `app/components/OutsourcingRequestForm.tsx`
|
||||
|
||||
- [ ] **Step 1:** 페이지를 다크 토큰으로 재스킨:
|
||||
- Hero 축약(타이포+간단 필드 배경 — HeroField 재사용 가능, 높이 60vh)
|
||||
- `#showcase` 섹션 신설: `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />` — 기존 #portfolio 위치에 배치하고 `id="showcase"`와 `id="portfolio"` 모두 도달 가능하게(섹션에 showcase, 내부 앵커 div에 portfolio)
|
||||
- 기존 실사례 6건(운영 시스템)은 PROOF 스타일 카드로 유지
|
||||
- 제공 분야·프로세스·FAQ를 다크 카드로 재스킨 (카피 무수정)
|
||||
- `#contact` 의뢰 폼: OutsourcingRequestForm을 다크 스킨으로 — **INPUT_STYLE 상수·카드 배경 등 스타일 값만 변경, 로직·검증·API·단계 구조 무수정** (goNext 스테일 클로저 경고 주석 보존)
|
||||
- [ ] **Step 2:** `npm run build` + dev: `/outsourcing` 200, 앵커 3+1종(process/portfolio/showcase/contact) 존재, 폼 1단계 카드 렌더
|
||||
- [ ] **Step 3:** Commit — `feat(outsourcing): Deep Field 재스킨 + 쇼케이스 풀 그리드`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E + 성능 검증
|
||||
|
||||
- [ ] **Step 1: 자동** — `npm test`(16) + `npm run build` + prod 서버 curl:
|
||||
- `/` 200 + 새 히어로 카피 존재 + "7년차|대기업" 0건 / `/outsourcing` 200 + id="showcase" / 폼 마크업 존재
|
||||
- 회귀: `/products` 200(라이트 유지), `/work/saju` 404, `/music/packs` 308, POST `/api/contact` 빈 body 400, `/api/orders` 401, `/track/x` 404
|
||||
- 번들 확인: `.next` 빌드 출력에서 `/` 페이지 First Load JS — three.js가 별도 청크인지(메인 First Load에 포함 안 됨), 합계가 과도하지 않은지 보고
|
||||
- [ ] **Step 2: 수동 체크리스트 (CEO + 컨트롤러)**
|
||||
- 데스크톱: 히어로 커서 반응·쇼케이스 hover 시차·스크롤 연출·카운터
|
||||
- 모바일 375px: lite 모드(드리프트만), 레이아웃
|
||||
- DevTools에서 prefers-reduced-motion 에뮬레이션 → 정적 폴백이 그 자체로 완성돼 보이는지
|
||||
- 탭 비활성 시 CPU 사용 0 근접 확인
|
||||
- 의뢰 폼 4단계 제출 회귀 1회
|
||||
- [ ] **Step 3:** 최종 보고
|
||||
|
||||
---
|
||||
|
||||
## 후속 (별도 스펙·플랜)
|
||||
|
||||
샘플 8종 Deep Field 컨셉 리뉴얼 — 2개씩 4회차, 완료 슬롯마다 `lib/showcase.ts`에 href 추가로 활성화.
|
||||
@@ -0,0 +1,85 @@
|
||||
# "Deep Field" 랜딩 경험 — 메인·외주 다크 캔버스 + WebGL 쇼케이스
|
||||
|
||||
- **작성일**: 2026-06-12
|
||||
- **상태**: CEO 승인 완료 (Visual Companion 세션으로 방향·구조 확정)
|
||||
- **목표**: 고객이 "AI가 만든 디자인"으로 느끼지 않는 새로운 경험의 랜딩. phantom.land 류의 커서 반응형 WebGL·몰입형 쇼케이스·볼드 타이포·스크롤 연출을 메인(/)과 /outsourcing에 적용하고, 외주 레퍼런스 쇼케이스를 페이지의 주인공으로.
|
||||
|
||||
---
|
||||
|
||||
## 0. 확정된 결정 (CEO)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 레퍼런스 포인트 | phantom.land의 4요소 전부: 커서 반응 비주얼 + 몰입형 쇼케이스 + 볼드 타이포 + 스크롤 연출 |
|
||||
| 기술 수준 | **three.js WebGL 풀장착** (성능·폴백 조건은 §6) |
|
||||
| 범위 | **메인(/) + /outsourcing** 다크 캔버스 통일. 거래 페이지는 라이트 유지 + 브릿지 |
|
||||
| 쇼케이스 콘텐츠 | 기존 샘플 8종을 각 컨셉에 맞게 리뉴얼해 연결 — **샘플 리뉴얼은 별도 후속 스펙**, 이번엔 쇼케이스 시스템 + 아트 타일 |
|
||||
| 톤 | 히어로·본문·쇼케이스 동일 톤 (다크 하이브리드 아님 — 페이지 전체 통일) |
|
||||
| **카피** | **"대기업 7년차 개발자" 류 경력 강조 금지.** 신뢰는 운영 실증으로: "24시간 돌아가는 실서비스를 직접 설계·운영" 축 ([[feedback-copy-no-career-emphasis]]) |
|
||||
|
||||
## 1. 컨셉 — "Deep Field"
|
||||
|
||||
깊은 네이비 우주(필드) 위에 작업물이 떠오른다. 다크 베이스는 순수 검정이 아닌 **브랜드 네이비 혈통**(`#070d1a` 계열)으로, 기존 `--jsm-navy` 푸터와 한 핏줄. 포인트는 기존 `--jsm-accent`(#1d4ed8)에 다크 위 가독용 밝은 변형(#60a5fa)을 추가.
|
||||
|
||||
### 다크 토큰 확장 (`globals.css`)
|
||||
```
|
||||
--jsm-dark-bg: #070d1a /* 페이지 베이스 */
|
||||
--jsm-dark-surface: rgba(255,255,255,.03) /* 카드 */
|
||||
--jsm-dark-line: rgba(148,163,184,.14) /* 보더 */
|
||||
--jsm-dark-ink: #f8fafc /* 헤드라인 */
|
||||
--jsm-dark-soft: #94a3b8 /* 보조 텍스트 */
|
||||
--jsm-accent-bright:#60a5fa /* 다크 위 포인트 */
|
||||
```
|
||||
기존 라이트 토큰은 무수정 (거래 페이지가 사용).
|
||||
|
||||
## 2. 메인(/) 스크롤 구조 (승인된 목업 기준)
|
||||
|
||||
1. **HERO** — WebGL 파티클/포인트 필드가 커서를 자기장처럼 따라 굴절. 거대 타이포(2줄, letter-spacing 타이트): 카피는 "생각을 동작하는 소프트웨어로." 방향 (구현 시 디자인 스킬로 다듬되 경력 표현 금지). 서브: 운영 실증 한 줄. CTA 2개(프로젝트 문의 솔리드 / 소프트웨어 보기 고스트). 스크롤 시작 시 필드가 흩어지며 다음 섹션으로.
|
||||
2. **SHOWCASE (주인공)** — "이런 걸 만들어 드립니다". 비대칭 그리드(대형 1 + 보조 2 페턴, 데스크톱) / 세로 스택(모바일). 각 카드는 컨셉별 고유 컬러 월드를 가진 WebGL 평면 — hover 시 굴절·미세 확대, 클릭 시 풀스크린 몰입 전환 후 데모로 이동. 8슬롯 체계: 리뉴얼 완료된 샘플부터 클릭 활성, 미완료 슬롯은 아트 타일(비활성, "coming" 라벨 없이 자연스럽게 비클릭). [전체 보기 → /outsourcing#showcase]
|
||||
3. **PROCESS** — 4단계. 스크롤 진입 시 연결선이 그려지며 단계가 순차 점등.
|
||||
4. **PROOF** — 운영 중 시스템 3종(주식 자동매매/청약 매칭/AI 파이프라인) + 카운터 스탯("운영 중인 실서비스가 곧 포트폴리오"). 숫자는 스크롤 진입 시 카운트업.
|
||||
5. **SOFTWARE + CTA** — 제품 카드(라이트 카드가 다크 위에 떠 있는 대비), 기존 동적 products 연동 유지. 최종 CTA 밴드.
|
||||
|
||||
## 3. /outsourcing 구조
|
||||
|
||||
동일 다크 톤. Hero(축약) → **#showcase 풀 그리드(8슬롯 전체)** → 제공 분야 → 프로세스(상세 6단계) → FAQ → **의뢰 폼**(4단계 폼 — 다크 스타일로 재스킨, **로직·검증·API 무수정**). 기존 앵커(#process/#portfolio/#contact) 유지 — #portfolio는 #showcase로 통합하되 구 앵커도 동작(중복 id 불가하니 #portfolio 위치에 showcase 배치).
|
||||
|
||||
## 4. 쇼케이스 카드 시스템 (8슬롯)
|
||||
|
||||
기존 샘플 8종(`/work/website/samples/*`)의 컨셉을 슬롯으로 승계: corporate / commerce(shopping) / dashboard / bakery / portfolio / 기타 3종(구현 시 실제 샘플 목록 확인). 각 슬롯: 컨셉명(모노스페이스 라벨) + 고유 컬러 그래디언트 월드 + 한 줄 설명. 카드 비주얼은 **이번 스펙에서 신규 제작하는 아트 타일**(WebGL 텍스처/캔버스), 스크린샷 의존 없음 → 샘플 리뉴얼 전에도 완성도 유지. 샘플이 리뉴얼되면 해당 슬롯에 라이브 링크 활성화(데이터는 `lib/showcase.ts` 단일 배열로 관리 — `{ slug, label, title, desc, palette, href?: string }`, href 있으면 클릭 가능).
|
||||
|
||||
## 5. 네비·브릿지 전략
|
||||
|
||||
- **TopNav route-aware**: 다크 페이지(`/`, `/outsourcing`)에서는 투명→스크롤 시 다크 배경, 라이트 페이지에서는 기존 흰색 동작 유지. `usePathname` 기반 분기 (auth 로직 무수정).
|
||||
- 푸터는 전 페이지 동일 네이비(기존) — 자연 브릿지.
|
||||
- 거래·계정 페이지(/products, /mypage, /track, /quote, /login, /legal)는 **라이트 유지, 무수정** (이번 스펙 범위 밖).
|
||||
|
||||
## 6. 기술·성능·접근성 (WebGL 풀장착의 조건)
|
||||
|
||||
- **three.js는 dynamic import** — 클라이언트 전용 청크, 첫 페인트는 서버 렌더 정적 콘텐츠(텍스트·레이아웃)가 담당. SEO 텍스트는 전부 SSR 유지.
|
||||
- **폴백 3단계**: ① `prefers-reduced-motion` → WebGL 미기동, 정적 그래디언트 ② 모바일/저성능(`navigator.hardwareConcurrency<4` 또는 뷰포트<768) → 경량 모드(파티클 수 1/4, 쇼케이스 굴절 비활성·CSS 전환) ③ WebGL 컨텍스트 실패 → 정적 폴백. 폴백 상태에서도 페이지는 완전한 경험이어야 함 (그래디언트·타이포·CSS 모션).
|
||||
- 단일 `<canvas>` 재사용(히어로) + 쇼케이스는 카드별 경량 셰이더 또는 IntersectionObserver로 화면 내만 렌더. `requestAnimationFrame`은 탭 비활성 시 정지.
|
||||
- 번들: three.js 코어만(없는 기능 import 금지), 목표 추가 JS ≤ 200KB gzip.
|
||||
- 컴포넌트 구조: `app/components/deepfield/` — `HeroField.tsx`(WebGL 히어로) / `ShowcaseGrid.tsx` + `ShowcaseCard.tsx` / `ScrollReveal.tsx`(공용 스크롤 연출) / `useWebGLSupport.ts`(폴백 판정 훅). 페이지는 서버 컴포넌트 유지, WebGL 부분만 클라이언트 경계.
|
||||
|
||||
## 7. 카피 원칙
|
||||
|
||||
- 경력·소속 자격("7년차", "대기업") 표현 전면 제거 — **metadata description·jsonLd 포함**.
|
||||
- 신뢰 축: "24시간 돌아가는 실서비스 15+를 직접 설계·운영합니다", "납품으로 끝나지 않습니다 — 직접 쓰는 사람이 만듭니다" 류.
|
||||
- 한글 헤드라인 우선, 영문은 라벨·모노스페이스 디테일에만.
|
||||
|
||||
## 8. 무수정 보존 (회귀 금지선)
|
||||
|
||||
- 의뢰 폼(OutsourcingRequestForm) 로직·검증·API 호출 / products 동적 연동 로직 / 모든 라우팅·redirect / GA·jsonLd 구조(내용 카피만 갱신) / 거래·계정·admin 페이지 전부.
|
||||
|
||||
## 9. 검증
|
||||
|
||||
- `npm test` + `npm run build` + Phase 1~3 E2E 매트릭스 회귀 (숨김 404·redirect·API 401)
|
||||
- 수동: 데스크톱(커서 반응·쇼케이스 hover·스크롤 연출), 모바일 375px(경량 모드), `prefers-reduced-motion` 에뮬레이션(정적 폴백), 탭 전환 CPU, Lighthouse 성능 확인(LCP가 WebGL에 막히지 않는지)
|
||||
- 의뢰 폼 4단계 제출 회귀 1회
|
||||
|
||||
## 10. 의도적 제외 (후속 스펙)
|
||||
|
||||
- **샘플 8종 리뉴얼** (별도 스펙 — 2개씩 점진, 완료 시 쇼케이스 슬롯 활성화)
|
||||
- 거래·계정 페이지 다크 전환
|
||||
- 쇼케이스 클릭 몰입 전환의 풀스크린 WebGL 트랜지션 고도화 (1차는 절제된 전환으로 시작)
|
||||
26
lib/__tests__/deepfield-mode.test.ts
Normal file
26
lib/__tests__/deepfield-mode.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideFieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
|
||||
|
||||
describe('decideFieldMode', () => {
|
||||
it('데스크톱 + WebGL = full', () => {
|
||||
expect(decideFieldMode(base)).toBe('full');
|
||||
});
|
||||
it('reduced-motion이면 무조건 static', () => {
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
|
||||
});
|
||||
it('WebGL 미지원이면 static', () => {
|
||||
expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
|
||||
});
|
||||
it('모바일 뷰포트(<768)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
|
||||
});
|
||||
it('저성능 코어(<4)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
|
||||
});
|
||||
it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
|
||||
});
|
||||
});
|
||||
17
lib/deepfield-mode.ts
Normal file
17
lib/deepfield-mode.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type FieldMode = 'full' | 'lite' | 'static';
|
||||
|
||||
export interface FieldEnv {
|
||||
reducedMotion: boolean;
|
||||
webglSupported: boolean;
|
||||
hardwareConcurrency: number; // 미보고 시 0
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
|
||||
export function decideFieldMode(env: FieldEnv): FieldMode {
|
||||
if (env.reducedMotion) return 'static';
|
||||
if (!env.webglSupported) return 'static';
|
||||
if (env.viewportWidth < 768) return 'lite';
|
||||
if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
|
||||
return 'full';
|
||||
}
|
||||
22
lib/showcase.ts
Normal file
22
lib/showcase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Deep Field 쇼케이스 8슬롯 — 단일 소스.
|
||||
* href가 있는 슬롯만 클릭 가능 (샘플 리뉴얼 완료 시 href 추가). */
|
||||
export interface ShowcaseSlot {
|
||||
slug: string;
|
||||
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
||||
title: string; // 카드 타이틀 (한글)
|
||||
desc: string; // 한 줄 설명
|
||||
palette: [string, string]; // 카드 고유 그래디언트 월드 [from, to]
|
||||
accent: string; // 카드 포인트 컬러
|
||||
href?: string; // 리뉴얼 완료된 샘플의 데모 링크
|
||||
}
|
||||
|
||||
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
||||
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 IR까지', palette: ['#13203a', '#0d2c54'], accent: '#60a5fa' },
|
||||
{ slug: 'shopping', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', palette: ['#1a1430', '#341a4f'], accent: '#c4b5fd' },
|
||||
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', palette: ['#0f2922', '#14503c'], accent: '#6ee7b7' },
|
||||
{ slug: 'bakery', label: 'local shop', title: '로컬 매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', palette: ['#2b1a10', '#4f2d14'], accent: '#fdba74' },
|
||||
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', palette: ['#101418', '#23272d'], accent: '#e2e8f0' },
|
||||
{ slug: 'game', label: 'game', title: '게임 프로모션', desc: '세계관에 빠져들게 하는 런칭 페이지', palette: ['#250f23', '#4a1342'], accent: '#f0abfc' },
|
||||
{ slug: 'interior', label: 'interior', title: '인테리어 스튜디오', desc: '공간의 톤을 그대로 옮긴 쇼룸', palette: ['#1f2218', '#3a4028'], accent: '#d9f99d' },
|
||||
{ slug: 'reading', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', palette: ['#101b2b', '#1f3a5f'], accent: '#93c5fd' },
|
||||
];
|
||||
67
package-lock.json
generated
67
package-lock.json
generated
@@ -28,13 +28,15 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"resend": "^6.9.1",
|
||||
"solarlunar": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.184.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
@@ -324,6 +326,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -2149,6 +2158,13 @@
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -2280,12 +2296,41 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.184.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
|
||||
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -4963,6 +5008,13 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -7192,6 +7244,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -9744,6 +9803,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@@ -30,13 +30,15 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"resend": "^6.9.1",
|
||||
"solarlunar": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.184.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
Reference in New Issue
Block a user