spec(commit fd40777)을 30+ task / 130+ step으로 분해. Phase 1: shell+토큰+공통
컴포넌트, Phase 2~5: home/saju/today/match 라우트 교체, Phase 6: v1 cleanup +
QA. 각 task는 test/run/implement/verify/commit 5-step 또는 시각 검증 step 포함.
self-review 통과 (spec 12절 모두 cover, placeholder/type inconsistency 없음).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2971 lines
107 KiB
Markdown
2971 lines
107 KiB
Markdown
# 호령 사주 UI v2 리디자인 Implementation Plan
|
||
|
||
> **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.
|
||
|
||
**Goal:** 백호 사주도사 프로토타입의 디자인 시스템을 web-ui의 `/saju` 라우트 4개에 동시 적용하고 `/saju/me` placeholder를 신설한다. 모바일/데스크탑은 1024px 경계로 컴포넌트 분리.
|
||
|
||
**Architecture:** `_shell/` 디렉토리에 디자인 시스템 (토큰 CSS + 13 공통 컴포넌트 + helpers + hook)을 두고, 각 페이지는 `useViewportMode()`로 `views/<page>.{mobile,desktop}.jsx`를 분기한다. 기존 `useSajuReading`/`useSajuForm` hook과 saju-lab API는 무변경 — 응답을 디자인 mock 필드로 매핑하는 helper만 추가.
|
||
|
||
**Tech Stack:** React 18, Vite, React Router v6, vanilla CSS (CSS variables + scope), Pretendard fallback, Google Fonts (Nanum Myeongjo/Nanum Gothic/Gowun Batang). 기존 `web-ui` 컨벤션 유지.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-26-saju-ui-v2-redesign-design.md` (commit `fd40777`)
|
||
|
||
**Reference design:** `C:\Users\jaeoh\Desktop\workspace\source\images\saju_page\사주풀이\` — babel/standalone React 11 파일 + styles.css 프로토타입
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### 신규 (`web-ui/src/pages/saju/`)
|
||
|
||
```
|
||
_shell/
|
||
├── tokens.css # CSS 변수 (--navy, --ivory, --gold, ...)
|
||
├── shell.css # paper-bg, night-bg, mt-wash, OrnateFrame, screenIn, paw-bob
|
||
├── useViewportMode.js
|
||
├── BottomNav.jsx
|
||
├── DesktopHeader.jsx
|
||
├── Mascot.jsx
|
||
├── MascotBubble.jsx
|
||
├── OrnateFrame.jsx
|
||
├── OrnamentBloom.jsx
|
||
├── TopRibbon.jsx
|
||
├── TitleBlock.jsx
|
||
├── PrimaryButton.jsx
|
||
├── GhostButton.jsx
|
||
├── InputRow.jsx
|
||
├── Icons.jsx
|
||
└── helpers/
|
||
├── daeunLabel.js
|
||
├── deriveTraits.js
|
||
├── colorMap.js
|
||
└── hexA.js
|
||
|
||
views/
|
||
├── home.mobile.jsx
|
||
├── home.desktop.jsx
|
||
├── saju.mobile.jsx
|
||
├── saju.desktop.jsx
|
||
├── today.mobile.jsx
|
||
├── today.desktop.jsx
|
||
├── match.mobile.jsx
|
||
└── match.desktop.jsx
|
||
|
||
Me.jsx # placeholder (mobile/desktop 공통)
|
||
```
|
||
|
||
### 교체 (기존 파일 본문 전면 교체, 파일명 유지)
|
||
- `pages/saju/Saju.jsx`
|
||
- `pages/saju/SajuResult.jsx`
|
||
- `pages/saju/Today.jsx`
|
||
- `pages/saju/Compatibility.jsx`
|
||
- `pages/saju/CompatibilityResult.jsx` (라이트 리스타일만)
|
||
|
||
### 수정
|
||
- `web-ui/index.html` (Google Fonts link 추가)
|
||
- `web-ui/src/routes.jsx` (Me lazy import + me 라우트 추가)
|
||
|
||
### 삭제 (Phase 6에서 일괄)
|
||
- `pages/saju/components/` 전체 (12 파일)
|
||
- `pages/saju/Saju.css`
|
||
|
||
### 보존
|
||
- `pages/saju/hooks/useSajuForm.js`
|
||
- `pages/saju/hooks/useSajuReading.js`
|
||
- `web-ui/public/images/saju/horyung/` 7 PNG
|
||
- `web-ui/src/api.js` saju 헬퍼
|
||
|
||
---
|
||
|
||
# Phase 1 — Shell + 토큰 + 공통 컴포넌트
|
||
|
||
기존 4 페이지 무손상. 신규 `/saju/me` 라우트 진입 시 placeholder 표시.
|
||
|
||
## Task 1.1: Google Fonts link 추가
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/index.html` (head 내 `<title>` 다음 줄에 추가)
|
||
|
||
- [ ] **Step 1: index.html에 폰트 preconnect + link 추가**
|
||
|
||
`<head>` 안 `<title>` 다음에 추가:
|
||
```html
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Nanum+Gothic:wght@400;700;800&family=Gowun+Batang:wght@400;700&display=swap"
|
||
rel="stylesheet"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 2: dev server 띄워 폰트 로드 확인**
|
||
|
||
Run: `cd web-ui && npm run dev`
|
||
Open `http://localhost:3007` → Network 탭에서 `fonts.googleapis.com/css2` 200 응답, `fonts.gstatic.com/s/...` woff2 파일 4개 이상 다운로드 확인
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||
git add index.html
|
||
git commit -m "feat(saju-ui-v2): Google Fonts (Nanum Myeongjo/Gothic/Gowun Batang) preconnect + link"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.2: tokens.css 작성
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/tokens.css`
|
||
|
||
- [ ] **Step 1: tokens.css 작성**
|
||
|
||
`.saju-v2` scope 안에 디자인 토큰 정의 (글로벌 오염 방지):
|
||
```css
|
||
/* 호령 사주 v2 — 디자인 토큰 */
|
||
.saju-v2 {
|
||
/* Brand palette */
|
||
--navy: #1F2A44;
|
||
--navy-deep: #141B30;
|
||
--navy-soft: #2E3B5A;
|
||
--ivory: #F7F2E8;
|
||
--ivory-soft: #FBF7EF;
|
||
--ivory-warm: #F0E9D9;
|
||
--gold: #D4AF37;
|
||
--gold-soft: #E8C76B;
|
||
--gold-dim: #B89530;
|
||
--green: #4E6B5C;
|
||
--green-soft: #6E8B7C;
|
||
--green-bg: #E6EBE5;
|
||
--purple: #6A4C7C;
|
||
--purple-soft: #8B6C9C;
|
||
--purple-bg: #ECE6F0;
|
||
--pink: #F2C7CD;
|
||
--pink-deep: #D89098;
|
||
--pink-bg: #FBE8EB;
|
||
--gray: #6B6B6B;
|
||
--gray-soft: #9A968D;
|
||
--gray-line: rgba(31, 42, 68, 0.10);
|
||
--gray-line-strong: rgba(31, 42, 68, 0.18);
|
||
|
||
/* Element colors (오행) */
|
||
--el-wood: #4E6B5C;
|
||
--el-fire: #C04A4A;
|
||
--el-earth: #A67B3F;
|
||
--el-metal: #D4AF37;
|
||
--el-water: #3A5A8C;
|
||
|
||
/* Shadows */
|
||
--shadow-card: 0 2px 8px rgba(31, 42, 68, 0.04), 0 8px 24px rgba(31, 42, 68, 0.06);
|
||
--shadow-pop: 0 8px 28px rgba(31, 42, 68, 0.16);
|
||
--shadow-dark: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||
|
||
/* Fonts */
|
||
--font-title: 'Nanum Myeongjo', 'Gowun Batang', serif;
|
||
--font-body: 'Nanum Gothic', system-ui, -apple-system, sans-serif;
|
||
|
||
/* Layout */
|
||
--content-max-desktop: 1200px;
|
||
--bottom-nav-h: 72px;
|
||
--desktop-header-h: 64px;
|
||
|
||
color: var(--navy);
|
||
font-family: var(--font-body);
|
||
-webkit-font-smoothing: antialiased;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
|
||
.saju-v2 * { box-sizing: border-box; }
|
||
|
||
.saju-v2 .font-title {
|
||
font-family: var(--font-title);
|
||
font-weight: 800;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.saju-v2 button {
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.saju-v2 button:focus-visible {
|
||
outline: 2px solid var(--gold);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* hide scrollbar utility */
|
||
.saju-v2 .no-scrollbar::-webkit-scrollbar { display: none; }
|
||
.saju-v2 .no-scrollbar { scrollbar-width: none; }
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/tokens.css
|
||
git commit -m "feat(saju-ui-v2): tokens.css — 디자인 변수 + 한글 폰트 토큰 (.saju-v2 scope)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.3: shell.css 작성
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/shell.css`
|
||
|
||
- [ ] **Step 1: shell.css 작성**
|
||
|
||
```css
|
||
/* 호령 사주 v2 — 배경 + ornament + animation */
|
||
|
||
/* paper texture */
|
||
.saju-v2 .paper-bg {
|
||
background:
|
||
radial-gradient(ellipse at top, rgba(212, 175, 55, 0.06), transparent 60%),
|
||
radial-gradient(ellipse at bottom, rgba(106, 76, 124, 0.04), transparent 60%),
|
||
linear-gradient(180deg, var(--ivory) 0%, var(--ivory-soft) 100%);
|
||
position: relative;
|
||
}
|
||
.saju-v2 .paper-bg::after {
|
||
content: '';
|
||
position: absolute; inset: 0; pointer-events: none;
|
||
background-image:
|
||
radial-gradient(circle at 20% 30%, rgba(180, 140, 80, 0.04) 0, transparent 40%),
|
||
radial-gradient(circle at 80% 70%, rgba(180, 140, 80, 0.04) 0, transparent 40%);
|
||
}
|
||
|
||
/* night sky */
|
||
.saju-v2 .night-bg {
|
||
background:
|
||
radial-gradient(ellipse 80% 50% at 30% 20%, rgba(232, 199, 107, 0.18), transparent 60%),
|
||
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(106, 76, 124, 0.3), transparent 60%),
|
||
linear-gradient(180deg, var(--navy-deep) 0%, var(--navy) 55%, #1A2238 100%);
|
||
position: relative;
|
||
color: var(--ivory);
|
||
}
|
||
|
||
/* mountain wash (desktop hero) */
|
||
.saju-v2 .mt-wash {
|
||
position: relative;
|
||
background:
|
||
radial-gradient(ellipse 70% 50% at 10% 80%, rgba(31, 42, 68, 0.06), transparent 65%),
|
||
radial-gradient(ellipse 60% 40% at 90% 70%, rgba(31, 42, 68, 0.05), transparent 65%),
|
||
radial-gradient(ellipse 100% 60% at 50% 100%, rgba(212, 175, 55, 0.04), transparent 70%),
|
||
linear-gradient(180deg, var(--ivory-soft) 0%, #F4ECDB 100%);
|
||
}
|
||
.saju-v2 .mt-wash::before,
|
||
.saju-v2 .mt-wash::after {
|
||
content: ''; position: absolute; pointer-events: none;
|
||
background-repeat: no-repeat; opacity: 0.35; background-size: contain;
|
||
}
|
||
.saju-v2 .mt-wash::before {
|
||
left: 0; bottom: 0; width: 320px; height: 160px;
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 160' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.45'><path d='M0 150 L40 90 L80 120 L130 60 L180 110 L220 80 L260 120 L310 70 L320 100 L320 160 L0 160 Z'/><path d='M30 130 L70 100 L110 130 L150 95 L200 120 L240 100 L280 120 L320 110' opacity='0.6'/></svg>");
|
||
}
|
||
.saju-v2 .mt-wash::after {
|
||
right: 0; bottom: 0; width: 380px; height: 180px;
|
||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 380 180' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.4'><path d='M0 160 L50 100 L100 140 L160 70 L220 130 L280 90 L330 140 L380 110 L380 180 L0 180 Z'/></svg>");
|
||
}
|
||
|
||
/* screen entry */
|
||
@keyframes saju-screen-in {
|
||
from { transform: translateY(6px); opacity: 0.8; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
.saju-v2 .screen-in {
|
||
animation: saju-screen-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||
}
|
||
|
||
/* paw bob */
|
||
@keyframes saju-paw-bob {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-2px); }
|
||
}
|
||
.saju-v2 .paw-bob {
|
||
animation: saju-paw-bob 2.4s ease-in-out infinite;
|
||
display: inline-block;
|
||
}
|
||
|
||
/* page container */
|
||
.saju-v2 .page {
|
||
min-height: 100vh;
|
||
padding-bottom: var(--bottom-nav-h);
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.saju-v2 .page {
|
||
padding-bottom: 0;
|
||
padding-top: var(--desktop-header-h);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/shell.css
|
||
git commit -m "feat(saju-ui-v2): shell.css — paper/night/mt-wash 배경 + screenIn/paw-bob 애니메이션"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.4: useViewportMode hook + 단위 테스트
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/useViewportMode.js`
|
||
- Test: `web-ui/src/pages/saju/_shell/useViewportMode.test.js`
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||
|
||
```js
|
||
// useViewportMode.test.js
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import useViewportMode from './useViewportMode';
|
||
|
||
describe('useViewportMode', () => {
|
||
beforeEach(() => {
|
||
window.innerWidth = 800;
|
||
});
|
||
|
||
it('returns mobile when width < 1024', () => {
|
||
window.innerWidth = 1023;
|
||
const { result } = renderHook(() => useViewportMode());
|
||
expect(result.current).toBe('mobile');
|
||
});
|
||
|
||
it('returns desktop when width >= 1024', () => {
|
||
window.innerWidth = 1024;
|
||
const { result } = renderHook(() => useViewportMode());
|
||
expect(result.current).toBe('desktop');
|
||
});
|
||
|
||
it('updates on resize', () => {
|
||
window.innerWidth = 800;
|
||
const { result } = renderHook(() => useViewportMode());
|
||
expect(result.current).toBe('mobile');
|
||
act(() => {
|
||
window.innerWidth = 1200;
|
||
window.dispatchEvent(new Event('resize'));
|
||
});
|
||
expect(result.current).toBe('desktop');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실행 — 실패 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/useViewportMode.test.js`
|
||
Expected: FAIL — `Cannot find module './useViewportMode'`
|
||
|
||
- [ ] **Step 3: hook 구현**
|
||
|
||
```js
|
||
// useViewportMode.js
|
||
import { useState, useEffect } from 'react';
|
||
|
||
export default function useViewportMode() {
|
||
const [mode, setMode] = useState(() =>
|
||
typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'desktop' : 'mobile'
|
||
);
|
||
useEffect(() => {
|
||
const onResize = () => {
|
||
const next = window.innerWidth >= 1024 ? 'desktop' : 'mobile';
|
||
setMode((prev) => (prev === next ? prev : next));
|
||
};
|
||
window.addEventListener('resize', onResize);
|
||
return () => window.removeEventListener('resize', onResize);
|
||
}, []);
|
||
return mode;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 통과 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/useViewportMode.test.js`
|
||
Expected: 3 passed
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/useViewportMode.js web-ui/src/pages/saju/_shell/useViewportMode.test.js
|
||
git commit -m "feat(saju-ui-v2): useViewportMode hook (1024px breakpoint) + 3 tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.5: helpers — hexA + daeunLabel + deriveTraits + colorMap + tests
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/helpers/hexA.js`
|
||
- Create: `web-ui/src/pages/saju/_shell/helpers/daeunLabel.js`
|
||
- Create: `web-ui/src/pages/saju/_shell/helpers/deriveTraits.js`
|
||
- Create: `web-ui/src/pages/saju/_shell/helpers/colorMap.js`
|
||
- Test: `web-ui/src/pages/saju/_shell/helpers/helpers.test.js`
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||
|
||
```js
|
||
// helpers.test.js
|
||
import { describe, it, expect } from 'vitest';
|
||
import hexA from './hexA';
|
||
import daeunLabel from './daeunLabel';
|
||
import deriveTraits from './deriveTraits';
|
||
import { elementColor } from './colorMap';
|
||
|
||
describe('hexA', () => {
|
||
it('converts hex with alpha', () => {
|
||
expect(hexA('#1F2A44', 0.5)).toBe('rgba(31,42,68,0.5)');
|
||
});
|
||
it('handles 3-digit hex', () => {
|
||
expect(hexA('#abc', 1)).toBe('rgba(170,187,204,1)');
|
||
});
|
||
});
|
||
|
||
describe('daeunLabel', () => {
|
||
it('maps age ranges', () => {
|
||
expect(daeunLabel(5)).toBe('성장기');
|
||
expect(daeunLabel(15)).toBe('학습기');
|
||
expect(daeunLabel(25)).toBe('도전기');
|
||
expect(daeunLabel(35)).toBe('성장기');
|
||
expect(daeunLabel(45)).toBe('전성기');
|
||
expect(daeunLabel(55)).toBe('안정기');
|
||
expect(daeunLabel(65)).toBe('정리기');
|
||
expect(daeunLabel(75)).toBe('여유기');
|
||
});
|
||
});
|
||
|
||
describe('deriveTraits', () => {
|
||
it('derives strong-element traits (sorted by score)', () => {
|
||
const traits = deriveTraits({ fire: 55, metal: 40, wood: 35, earth: 15, water: 20 }, []);
|
||
expect(traits.length).toBeLessThanOrEqual(6);
|
||
expect(traits[0].id).toBe('challenge'); // fire 가장 강함
|
||
expect(traits.map((t) => t.id)).toContain('lead'); // metal >= 30
|
||
});
|
||
it('always includes will trait', () => {
|
||
const traits = deriveTraits({ fire: 50, metal: 30, wood: 30, earth: 30, water: 30 }, []);
|
||
expect(traits.map((t) => t.id)).toContain('will');
|
||
});
|
||
});
|
||
|
||
describe('elementColor', () => {
|
||
it('maps element ids to CSS vars', () => {
|
||
expect(elementColor('wood')).toBe('var(--el-wood)');
|
||
expect(elementColor('fire')).toBe('var(--el-fire)');
|
||
expect(elementColor('unknown')).toBe('var(--navy)');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실행 — 실패 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/helpers/helpers.test.js`
|
||
Expected: FAIL — modules not found
|
||
|
||
- [ ] **Step 3: hexA.js 작성**
|
||
|
||
```js
|
||
// hexA.js
|
||
export default function hexA(hex, alpha) {
|
||
const h = hex.replace('#', '');
|
||
const expanded = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||
const n = parseInt(expanded, 16);
|
||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: daeunLabel.js 작성**
|
||
|
||
```js
|
||
// daeunLabel.js
|
||
export default function daeunLabel(age) {
|
||
if (age < 10) return '성장기';
|
||
if (age < 20) return '학습기';
|
||
if (age < 30) return '도전기';
|
||
if (age < 40) return '성장기';
|
||
if (age < 50) return '전성기';
|
||
if (age < 60) return '안정기';
|
||
if (age < 70) return '정리기';
|
||
return '여유기';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: colorMap.js 작성**
|
||
|
||
```js
|
||
// colorMap.js
|
||
const ELEMENT_TO_VAR = {
|
||
wood: 'var(--el-wood)',
|
||
fire: 'var(--el-fire)',
|
||
earth: 'var(--el-earth)',
|
||
metal: 'var(--el-metal)',
|
||
water: 'var(--el-water)',
|
||
};
|
||
|
||
const ELEMENT_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' };
|
||
const ELEMENT_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' };
|
||
|
||
export function elementColor(id) {
|
||
return ELEMENT_TO_VAR[id] || 'var(--navy)';
|
||
}
|
||
export function elementKo(id) {
|
||
return ELEMENT_KO[id] || '';
|
||
}
|
||
export function elementCh(id) {
|
||
return ELEMENT_CH[id] || '';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: deriveTraits.js 작성**
|
||
|
||
```js
|
||
// deriveTraits.js
|
||
const TRAIT_DEFS = {
|
||
fire: { id: 'challenge', ko: '도전정신', icon: 'challenge', color: 'var(--el-fire)' },
|
||
metal: { id: 'lead', ko: '리더십', icon: 'lead', color: 'var(--el-metal)' },
|
||
wood: { id: 'adapt', ko: '적응력', icon: 'adapt', color: 'var(--el-wood)' },
|
||
water: { id: 'wisdom', ko: '지혜', icon: 'wisdom', color: 'var(--el-water)' },
|
||
earth: { id: 'wealth', ko: '풍부함', icon: 'wealth', color: 'var(--el-earth)' },
|
||
};
|
||
|
||
const WILL_TRAIT = { id: 'will', ko: '의지', icon: 'will', color: 'var(--purple)' };
|
||
|
||
export default function deriveTraits(elements, sipsin = []) {
|
||
// strong elements first (score desc)
|
||
const sorted = Object.entries(elements || {})
|
||
.filter(([, v]) => typeof v === 'number')
|
||
.sort((a, b) => b[1] - a[1]);
|
||
|
||
const traits = [];
|
||
// strong elements (>= 30%)
|
||
for (const [el, score] of sorted) {
|
||
if (score >= 30 && TRAIT_DEFS[el]) {
|
||
traits.push(TRAIT_DEFS[el]);
|
||
}
|
||
}
|
||
// always include will
|
||
if (!traits.find((t) => t.id === 'will')) traits.push(WILL_TRAIT);
|
||
|
||
// pad up to 6 with remaining elements
|
||
for (const [el] of sorted) {
|
||
if (traits.length >= 6) break;
|
||
if (TRAIT_DEFS[el] && !traits.find((t) => t.id === TRAIT_DEFS[el].id)) {
|
||
traits.push(TRAIT_DEFS[el]);
|
||
}
|
||
}
|
||
return traits.slice(0, 6);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 테스트 통과 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/helpers/helpers.test.js`
|
||
Expected: 6+ passed
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/helpers/
|
||
git commit -m "feat(saju-ui-v2): _shell/helpers — hexA/daeunLabel/deriveTraits/colorMap + tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.6: Icons.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/Icons.jsx`
|
||
|
||
- [ ] **Step 1: Icons.jsx 작성 — 디자인 프로토타입 `icons.jsx`의 SVG path들 그대로 포팅**
|
||
|
||
각 아이콘은 functional component (stroke/size props 지원). 5개 nav 아이콘(IconHome/IconSun/IconHeart/IconYinYang/IconUser) + 보조 아이콘(IconPaw/IconChevron/IconSparkle):
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
const base = (size, stroke, strokeWidth = 1.5) => ({
|
||
width: size, height: size, fill: 'none', stroke,
|
||
strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||
});
|
||
|
||
export function IconHome({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||
<path d="M3 11l9-7 9 7v9a2 2 0 0 1-2 2h-3v-6h-8v6H5a2 2 0 0 1-2-2z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconSun({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||
<circle cx="12" cy="12" r="4" />
|
||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconHeart({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||
<path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 7a5.5 5.5 0 0 1 9.5 5c-2.5 4.5-9.5 9-9.5 9z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconYinYang({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||
<circle cx="12" cy="12" r="9" />
|
||
<path d="M12 3a4.5 4.5 0 0 0 0 9 4.5 4.5 0 0 1 0 9" />
|
||
<circle cx="12" cy="7.5" r="1" fill={stroke} />
|
||
<circle cx="12" cy="16.5" r="1" fill={stroke} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconUser({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||
<circle cx="12" cy="8" r="4" />
|
||
<path d="M4 21c1-4 5-6 8-6s7 2 8 6" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconPaw({ size = 12, color = 'currentColor' }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill={color}>
|
||
<ellipse cx="6" cy="10" rx="2" ry="2.5" />
|
||
<ellipse cx="10" cy="6" rx="2" ry="2.5" />
|
||
<ellipse cx="14" cy="6" rx="2" ry="2.5" />
|
||
<ellipse cx="18" cy="10" rx="2" ry="2.5" />
|
||
<path d="M8 14c0-2 2-3 4-3s4 1 4 3-2 5-4 5-4-3-4-5z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconChevron({ size = 14, color = 'currentColor', dir = 'right' }) {
|
||
const rotate = { right: 0, down: 90, left: 180, up: 270 }[dir] || 0;
|
||
return (
|
||
<svg viewBox="0 0 24 24" width={size} height={size}
|
||
fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||
style={{ transform: `rotate(${rotate}deg)` }}>
|
||
<path d="M9 6l6 6-6 6" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function IconSparkle({ size = 12, color = 'currentColor' }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill={color}>
|
||
<path d="M12 2l2 7 7 2-7 2-2 7-2-7-7-2 7-2z" />
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/Icons.jsx
|
||
git commit -m "feat(saju-ui-v2): Icons.jsx — 5 nav + IconPaw/Chevron/Sparkle"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.7: Mascot.jsx + 테스트
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/Mascot.jsx`
|
||
- Test: `web-ui/src/pages/saju/_shell/Mascot.test.jsx`
|
||
|
||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||
|
||
```jsx
|
||
// Mascot.test.jsx
|
||
import React from 'react';
|
||
import { render } from '@testing-library/react';
|
||
import { describe, it, expect } from 'vitest';
|
||
import Mascot from './Mascot';
|
||
|
||
describe('Mascot', () => {
|
||
const VARIANTS = ['full', 'head', 'upper', 'greeting', 'thinking', 'pointing', 'happy'];
|
||
VARIANTS.forEach((v) => {
|
||
it(`renders variant=${v} with correct src`, () => {
|
||
const { container } = render(<Mascot variant={v} size={100} />);
|
||
const img = container.querySelector('img');
|
||
expect(img).toBeTruthy();
|
||
expect(img.getAttribute('src')).toContain('/images/saju/horyung/');
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실행 — 실패 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/Mascot.test.jsx`
|
||
Expected: FAIL — module not found
|
||
|
||
- [ ] **Step 3: Mascot.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
const VARIANT_TO_SRC = {
|
||
full: '/images/saju/horyung/horyung-main.png',
|
||
head: '/images/saju/horyung/horyung-bust.png',
|
||
upper: '/images/saju/horyung/horyung-front.png',
|
||
greeting: '/images/saju/horyung/horyung-greeting.png',
|
||
thinking: '/images/saju/horyung/horyung-thinking.png',
|
||
pointing: '/images/saju/horyung/horyung-pointing.png',
|
||
happy: '/images/saju/horyung/horyung-happy.png',
|
||
};
|
||
|
||
export default function Mascot({ variant = 'full', size = 200, style = {}, alt = '호령' }) {
|
||
const src = VARIANT_TO_SRC[variant] || VARIANT_TO_SRC.full;
|
||
return (
|
||
<img src={src} alt={alt} width={size} loading="lazy" style={{ display: 'block', ...style }} />
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 통과 확인**
|
||
|
||
Run: `cd web-ui && npx vitest run src/pages/saju/_shell/Mascot.test.jsx`
|
||
Expected: 7 passed
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/Mascot.jsx web-ui/src/pages/saju/_shell/Mascot.test.jsx
|
||
git commit -m "feat(saju-ui-v2): Mascot.jsx + 7 variant 매핑 test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.8: MascotBubble.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/MascotBubble.jsx`
|
||
|
||
- [ ] **Step 1: MascotBubble.jsx 작성 — 디자인 프로토타입 `common.jsx`의 동명 함수 포팅**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { IconPaw } from './Icons';
|
||
|
||
const PALETTES = {
|
||
ivory: { bg: '#FBF7EF', border: 'rgba(31,42,68,0.12)', text: '#1F2A44', paw: '#B89530' },
|
||
navy: { bg: 'rgba(255,255,255,0.06)', border: 'rgba(212,175,55,0.35)', text: '#F7F2E8', paw: '#D4AF37' },
|
||
green: { bg: '#FBF7EF', border: 'rgba(78,107,92,0.30)', text: '#1F2A44', paw: '#B89530' },
|
||
purple: { bg: '#FBF7EF', border: 'rgba(106,76,124,0.30)', text: '#1F2A44', paw: '#B89530' },
|
||
};
|
||
|
||
export default function MascotBubble({
|
||
text, align = 'left', tone = 'ivory', tail = true, paw = true, style = {},
|
||
}) {
|
||
const p = PALETTES[tone] || PALETTES.ivory;
|
||
return (
|
||
<div style={{
|
||
position: 'relative', background: p.bg, color: p.text,
|
||
border: `1px solid ${p.border}`, borderRadius: 14,
|
||
padding: '12px 14px',
|
||
fontSize: 13, lineHeight: 1.55, letterSpacing: '-0.01em',
|
||
maxWidth: 240, boxShadow: '0 2px 6px rgba(31,42,68,0.04)',
|
||
...style,
|
||
}}>
|
||
<div style={{ whiteSpace: 'pre-line' }}>{text}</div>
|
||
{paw && (
|
||
<div style={{ marginTop: 4, textAlign: 'right', color: p.paw, opacity: 0.8 }}>
|
||
<span className="paw-bob"><IconPaw size={12} color={p.paw} /></span>
|
||
</div>
|
||
)}
|
||
{tail && (
|
||
<div style={{
|
||
position: 'absolute', bottom: -7, [align]: 22,
|
||
width: 14, height: 14, background: p.bg,
|
||
borderRight: `1px solid ${p.border}`, borderBottom: `1px solid ${p.border}`,
|
||
transform: 'rotate(45deg)',
|
||
}} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/MascotBubble.jsx
|
||
git commit -m "feat(saju-ui-v2): MascotBubble.jsx — 4 tone (ivory/navy/green/purple) + paw-bob"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.9: OrnamentBloom + TopRibbon + OrnateFrame + TitleBlock
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/OrnamentBloom.jsx`
|
||
- Create: `web-ui/src/pages/saju/_shell/TopRibbon.jsx`
|
||
- Create: `web-ui/src/pages/saju/_shell/OrnateFrame.jsx`
|
||
- Create: `web-ui/src/pages/saju/_shell/TitleBlock.jsx`
|
||
|
||
- [ ] **Step 1: OrnamentBloom.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
export default function OrnamentBloom({ size = 18, color = '#D4AF37' }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 18 18" fill="none">
|
||
<circle cx="9" cy="9" r="2.4" fill={color} />
|
||
{[0, 60, 120, 180, 240, 300].map((angle) => (
|
||
<ellipse key={angle} cx="9" cy="4" rx="1.6" ry="3" fill={color} opacity="0.7"
|
||
transform={`rotate(${angle} 9 9)`} />
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: TopRibbon.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
function CloudOrnament({ width = 90, color = '#D4AF37', opacity = 0.85 }) {
|
||
return (
|
||
<svg width={width} height={width / 3.5} viewBox="0 0 90 26" fill="none" opacity={opacity}>
|
||
<path d="M5 18 Q12 6 24 12 Q36 4 48 14 Q60 6 72 14 Q82 8 88 18"
|
||
stroke={color} strokeWidth="1" fill="none" />
|
||
<circle cx="24" cy="12" r="1.4" fill={color} />
|
||
<circle cx="48" cy="14" r="1.4" fill={color} />
|
||
<circle cx="72" cy="14" r="1.4" fill={color} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export default function TopRibbon({ color = '#D4AF37', opacity = 0.5 }) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 0', opacity }}>
|
||
<CloudOrnament width={90} color={color} opacity={0.85} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: OrnateFrame.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import hexA from './helpers/hexA';
|
||
|
||
export default function OrnateFrame({
|
||
children, color = '#D4AF37', bg = 'transparent', radius = 14, padding = '20px',
|
||
style = {}, double = false,
|
||
}) {
|
||
return (
|
||
<div style={{
|
||
position: 'relative', borderRadius: radius,
|
||
background: bg, padding,
|
||
border: `1px solid ${hexA(color, 0.45)}`,
|
||
boxShadow: 'var(--shadow-card)',
|
||
...style,
|
||
}}>
|
||
{double && (
|
||
<div style={{
|
||
position: 'absolute', inset: 4, borderRadius: radius - 4,
|
||
border: `1px solid ${hexA(color, 0.3)}`, pointerEvents: 'none',
|
||
}} />
|
||
)}
|
||
{[[0,0,0],[0,1,90],[1,1,180],[1,0,270]].map(([x,y,r], i) => (
|
||
<svg key={i} width="12" height="12" viewBox="0 0 12 12" style={{
|
||
position: 'absolute',
|
||
[x ? 'right' : 'left']: 6,
|
||
[y ? 'bottom' : 'top']: 6,
|
||
transform: `rotate(${r}deg)`,
|
||
pointerEvents: 'none',
|
||
}}>
|
||
<path d="M0 4 L0 0 L4 0" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round" />
|
||
</svg>
|
||
))}
|
||
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: TitleBlock.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import OrnamentBloom from './OrnamentBloom';
|
||
|
||
export default function TitleBlock({
|
||
title, subtitle, color = '#1F2A44', subColor = '#6B6B6B',
|
||
center = true, withBloom = true, gold = '#D4AF37',
|
||
}) {
|
||
return (
|
||
<div style={{ textAlign: center ? 'center' : 'left' }}>
|
||
{withBloom && center && (
|
||
<div style={{
|
||
display: 'flex', justifyContent: 'center', gap: 12,
|
||
alignItems: 'center', marginBottom: 10, color: gold,
|
||
}}>
|
||
<svg width="40" height="6" viewBox="0 0 40 6">
|
||
<path d="M0 3 L36 3" stroke={gold} strokeWidth="1" />
|
||
<circle cx="38" cy="3" r="1.5" fill={gold} />
|
||
</svg>
|
||
<OrnamentBloom size={18} color={gold} />
|
||
<svg width="40" height="6" viewBox="0 0 40 6">
|
||
<circle cx="2" cy="3" r="1.5" fill={gold} />
|
||
<path d="M4 3 L40 3" stroke={gold} strokeWidth="1" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<h1 className="font-title" style={{
|
||
margin: 0, fontSize: 30, color, letterSpacing: '-0.02em',
|
||
}}>{title}</h1>
|
||
{subtitle && (
|
||
<div style={{
|
||
marginTop: 6, fontSize: 13, color: subColor, lineHeight: 1.55,
|
||
letterSpacing: '-0.01em',
|
||
}}>{subtitle}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/OrnamentBloom.jsx web-ui/src/pages/saju/_shell/TopRibbon.jsx web-ui/src/pages/saju/_shell/OrnateFrame.jsx web-ui/src/pages/saju/_shell/TitleBlock.jsx
|
||
git commit -m "feat(saju-ui-v2): OrnamentBloom + TopRibbon + OrnateFrame + TitleBlock"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.10: PrimaryButton + GhostButton + InputRow
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/PrimaryButton.jsx`
|
||
- Create: `web-ui/src/pages/saju/_shell/GhostButton.jsx`
|
||
- Create: `web-ui/src/pages/saju/_shell/InputRow.jsx`
|
||
|
||
- [ ] **Step 1: PrimaryButton.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import hexA from './helpers/hexA';
|
||
|
||
export default function PrimaryButton({
|
||
children, color = '#1F2A44', onClick, full = true, style = {}, gold = true, type = 'button',
|
||
}) {
|
||
return (
|
||
<button type={type} onClick={onClick} style={{
|
||
width: full ? '100%' : 'auto', padding: '14px 22px',
|
||
background: color, color: '#F7F2E8',
|
||
border: 'none', borderRadius: 12,
|
||
fontSize: 15, fontWeight: 700, letterSpacing: '-0.01em',
|
||
boxShadow: gold
|
||
? `0 2px 0 ${hexA(color, 0.4)}, 0 6px 18px ${hexA(color, 0.25)}, inset 0 1px 0 rgba(212,175,55,0.4)`
|
||
: '0 4px 14px rgba(31,42,68,0.18)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
...style,
|
||
}}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: GhostButton.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import hexA from './helpers/hexA';
|
||
|
||
export default function GhostButton({
|
||
children, color = '#1F2A44', onClick, full = true, style = {}, type = 'button',
|
||
}) {
|
||
return (
|
||
<button type={type} onClick={onClick} style={{
|
||
width: full ? '100%' : 'auto', padding: '13px 22px',
|
||
background: 'transparent', color, border: `1px solid ${hexA(color, 0.4)}`,
|
||
borderRadius: 12, fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
...style,
|
||
}}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: InputRow.jsx 작성 (Phase 2 home에서 사용)**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
|
||
export default function InputRow({
|
||
label, name, type = 'text', value, onChange, placeholder, error, children,
|
||
}) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', padding: '12px 14px',
|
||
borderBottom: '1px solid rgba(31,42,68,0.06)', gap: 12,
|
||
}}>
|
||
<label htmlFor={name} style={{
|
||
width: 80, fontSize: 12, color: '#6B6B6B', fontWeight: 700, letterSpacing: '-0.01em',
|
||
}}>{label}</label>
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{children || (
|
||
<input
|
||
id={name} name={name} type={type} value={value} onChange={onChange}
|
||
placeholder={placeholder}
|
||
style={{
|
||
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF',
|
||
fontSize: 13, color: '#1F2A44', fontFamily: 'inherit',
|
||
}}
|
||
/>
|
||
)}
|
||
{error && (
|
||
<span style={{ fontSize: 11, color: '#C04A4A', fontWeight: 700 }}>{error}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/PrimaryButton.jsx web-ui/src/pages/saju/_shell/GhostButton.jsx web-ui/src/pages/saju/_shell/InputRow.jsx
|
||
git commit -m "feat(saju-ui-v2): PrimaryButton + GhostButton + InputRow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.11: BottomNav.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/BottomNav.jsx`
|
||
|
||
- [ ] **Step 1: BottomNav.jsx 작성 — react-router NavLink 활용**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate, useLocation } from 'react-router-dom';
|
||
import { IconHome, IconSun, IconHeart, IconYinYang, IconUser } from './Icons';
|
||
import hexA from './helpers/hexA';
|
||
|
||
const NAV_ITEMS = [
|
||
{ id: 'home', to: '/saju', label: '홈', Icon: IconHome, accent: '#1F2A44' },
|
||
{ id: 'today', to: '/saju/today', label: '오늘의 운세', Icon: IconSun, accent: '#D4AF37' },
|
||
{ id: 'match', to: '/saju/compatibility', label: '궁합보기', Icon: IconHeart, accent: '#4E6B5C' },
|
||
{ id: 'saju', to: '/saju/result', label: '사주풀이', Icon: IconYinYang, accent: '#6A4C7C' },
|
||
{ id: 'me', to: '/saju/me', label: '마이페이지', Icon: IconUser, accent: '#6B6B6B' },
|
||
];
|
||
|
||
function pathToCurrent(pathname) {
|
||
if (pathname === '/saju' || pathname === '/saju/') return 'home';
|
||
if (pathname.startsWith('/saju/today')) return 'today';
|
||
if (pathname.startsWith('/saju/compatibility')) return 'match';
|
||
if (pathname.startsWith('/saju/result')) return 'saju';
|
||
if (pathname.startsWith('/saju/me')) return 'me';
|
||
return 'home';
|
||
}
|
||
|
||
export default function BottomNav({ theme = 'ivory' }) {
|
||
const navigate = useNavigate();
|
||
const { pathname } = useLocation();
|
||
const current = pathToCurrent(pathname);
|
||
const isDark = theme === 'navy';
|
||
const bg = isDark ? 'rgba(20,27,48,0.92)' : '#FBF7EF';
|
||
const border = isDark ? 'rgba(212,175,55,0.18)' : 'rgba(31,42,68,0.08)';
|
||
const inactive = isDark ? 'rgba(247,242,232,0.55)' : '#9A968D';
|
||
|
||
return (
|
||
<nav aria-label="사주 메뉴" style={{
|
||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||
paddingBottom: 'max(16px, env(safe-area-inset-bottom))', paddingTop: 8,
|
||
background: bg, borderTop: `1px solid ${border}`,
|
||
backdropFilter: 'blur(14px) saturate(140%)',
|
||
WebkitBackdropFilter: 'blur(14px) saturate(140%)',
|
||
zIndex: 30,
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'flex-end', padding: '0 6px' }}>
|
||
{NAV_ITEMS.map((item) => {
|
||
const active = item.id === current;
|
||
const activeColor = isDark
|
||
? (item.id === 'today' ? '#E8C76B' : item.accent === '#1F2A44' ? '#F7F2E8' : item.accent)
|
||
: item.accent;
|
||
const color = active ? activeColor : inactive;
|
||
return (
|
||
<button key={item.id} onClick={() => navigate(item.to)} aria-label={item.label}
|
||
aria-current={active ? 'page' : undefined}
|
||
style={{
|
||
background: 'transparent', border: 'none', padding: '6px 4px',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||
color, flex: 1, minWidth: 0, position: 'relative',
|
||
transition: 'color .2s',
|
||
}}>
|
||
<span style={{
|
||
width: 36, height: 28, borderRadius: 999,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
background: active ? hexA(item.accent, isDark ? 0.18 : 0.10) : 'transparent',
|
||
transition: 'background .2s',
|
||
}}>
|
||
<item.Icon size={20} stroke={color} strokeWidth={active ? 1.8 : 1.5} />
|
||
</span>
|
||
<span style={{
|
||
fontSize: 9.5, fontWeight: active ? 700 : 500, letterSpacing: '-0.04em',
|
||
whiteSpace: 'nowrap',
|
||
}}>{item.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
export { NAV_ITEMS };
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/BottomNav.jsx
|
||
git commit -m "feat(saju-ui-v2): BottomNav.jsx — 5 항목 + safe-area + active accent"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.12: DesktopHeader.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/_shell/DesktopHeader.jsx`
|
||
|
||
- [ ] **Step 1: DesktopHeader.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate, useLocation } from 'react-router-dom';
|
||
import { NAV_ITEMS } from './BottomNav';
|
||
|
||
function pathToCurrent(pathname) {
|
||
if (pathname === '/saju' || pathname === '/saju/') return 'home';
|
||
if (pathname.startsWith('/saju/today')) return 'today';
|
||
if (pathname.startsWith('/saju/compatibility')) return 'match';
|
||
if (pathname.startsWith('/saju/result')) return 'saju';
|
||
if (pathname.startsWith('/saju/me')) return 'me';
|
||
return 'home';
|
||
}
|
||
|
||
export default function DesktopHeader() {
|
||
const navigate = useNavigate();
|
||
const { pathname } = useLocation();
|
||
const current = pathToCurrent(pathname);
|
||
|
||
return (
|
||
<header style={{
|
||
position: 'sticky', top: 0, zIndex: 30, height: 64,
|
||
background: '#FBF7EF', borderBottom: '1px solid rgba(31,42,68,0.10)',
|
||
display: 'flex', alignItems: 'center', padding: '0 32px',
|
||
backdropFilter: 'blur(14px)',
|
||
}}>
|
||
<button onClick={() => navigate('/saju')} style={{
|
||
background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 10,
|
||
}}>
|
||
<span className="font-title" style={{
|
||
fontSize: 26, color: '#D4AF37', lineHeight: 1,
|
||
}}>壽</span>
|
||
<span className="font-title" style={{
|
||
fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em',
|
||
}}>호령사주</span>
|
||
</button>
|
||
<nav aria-label="사주 메뉴" style={{ marginLeft: 40, display: 'flex', gap: 8 }}>
|
||
{NAV_ITEMS.map((item) => {
|
||
const active = item.id === current;
|
||
return (
|
||
<button key={item.id} onClick={() => navigate(item.to)}
|
||
aria-current={active ? 'page' : undefined}
|
||
style={{
|
||
background: active ? 'rgba(31,42,68,0.06)' : 'transparent', border: 'none',
|
||
padding: '8px 14px', borderRadius: 8,
|
||
color: active ? item.accent : '#6B6B6B',
|
||
fontSize: 13, fontWeight: active ? 700 : 500, letterSpacing: '-0.02em',
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
}}>
|
||
<item.Icon size={16} stroke={active ? item.accent : '#9A968D'} strokeWidth={active ? 1.8 : 1.5} />
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/_shell/DesktopHeader.jsx
|
||
git commit -m "feat(saju-ui-v2): DesktopHeader.jsx — 로고 + 5 항목 horizontal nav"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.13: Me.jsx (placeholder)
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/Me.jsx`
|
||
|
||
- [ ] **Step 1: Me.jsx 작성**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import TopRibbon from './_shell/TopRibbon';
|
||
import Mascot from './_shell/Mascot';
|
||
import MascotBubble from './_shell/MascotBubble';
|
||
import OrnateFrame from './_shell/OrnateFrame';
|
||
|
||
const DISABLED_CARDS = [
|
||
{ title: '내 사주 이력', desc: '저장된 풀이를 한 번에' },
|
||
{ title: '북마크', desc: '관심 가는 해석 즐겨찾기' },
|
||
{ title: '설정', desc: '알림·테마·계정' },
|
||
{ title: '문의', desc: '호령이 듣고 있어요' },
|
||
];
|
||
|
||
export default function Me() {
|
||
const mode = useViewportMode();
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' && <DesktopHeader />}
|
||
<main className="page paper-bg screen-in">
|
||
<TopRibbon />
|
||
<div style={{
|
||
maxWidth: mode === 'desktop' ? 720 : 'none',
|
||
margin: '0 auto', padding: '24px 20px 40px',
|
||
textAlign: 'center',
|
||
}}>
|
||
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 12px' }} />
|
||
<MascotBubble tone="purple" tail={false}
|
||
text={'마이페이지는 곧 만나요.\n조금만 기다려주세요.'}
|
||
style={{ margin: '0 auto 24px' }} />
|
||
<div style={{ display: 'grid', gap: 12 }}>
|
||
{DISABLED_CARDS.map((card) => (
|
||
<OrnateFrame key={card.title} color="#6A4C7C" bg="#FBF7EF" padding="18px 16px"
|
||
style={{ opacity: 0.55, textAlign: 'left' }}>
|
||
<div className="font-title" style={{ fontSize: 16, color: '#1F2A44' }}>
|
||
{card.title}
|
||
</div>
|
||
<div style={{ marginTop: 6, fontSize: 12, color: '#6B6B6B' }}>{card.desc}</div>
|
||
</OrnateFrame>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/Me.jsx
|
||
git commit -m "feat(saju-ui-v2): Me.jsx — placeholder + 비활성 4 카드"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.14: routes.jsx 수정
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/routes.jsx`
|
||
|
||
- [ ] **Step 1: routes.jsx에 Me lazy import + 라우트 추가**
|
||
|
||
`pages/saju/Compatibility` import 다음 줄에:
|
||
```js
|
||
const SajuMe = lazy(() => import('./pages/saju/Me'));
|
||
```
|
||
|
||
`/saju` children 배열의 `compatibility/result` 다음에:
|
||
```js
|
||
{ path: 'me', element: <SajuMe /> },
|
||
```
|
||
|
||
- [ ] **Step 2: dev server에서 진입 확인**
|
||
|
||
Run: `cd web-ui && npm run dev`
|
||
Open `http://localhost:3007/saju/me` → Me placeholder 화면 + BottomNav 5 항목 + me 활성 표시 + 폰트 적용 확인. 1280px 브라우저로 늘려 DesktopHeader 전환 확인.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/routes.jsx
|
||
git commit -m "feat(saju-ui-v2): /saju/me 라우트 + Me 컴포넌트 lazy import"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1.15: Phase 1 시각 검증 + Phase commit tag
|
||
|
||
- [ ] **Step 1: 기존 4 페이지 무손상 확인**
|
||
|
||
`http://localhost:3007/saju` (v1 호령 메인), `/saju/result?rid=1` (v1 결과), `/saju/today?rid=1` (v1 오늘), `/saju/compatibility` (v1 placeholder) 모두 정상 렌더 — v2 변경이 영향 X.
|
||
|
||
- [ ] **Step 2: /saju/me 새 페이지 골든 패스**
|
||
|
||
- 모바일 (chrome devtools 390×844): paper-bg, TopRibbon, thinking Mascot, MascotBubble, 4 비활성 카드, BottomNav 하단 fixed + safe-area, me 항목 활성 표시
|
||
- 데스크탑 (1280×720): DesktopHeader 상단 sticky, 5 항목 nav, me 활성, BottomNav 없음
|
||
- 1023px ↔ 1024px 경계에서 자동 전환
|
||
|
||
- [ ] **Step 3: Phase 1 종료 tag commit (선택)**
|
||
|
||
```bash
|
||
git tag saju-ui-v2-phase1
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 2 — Home (`/saju`)
|
||
|
||
기존 `Saju.jsx`를 디자인 v2로 교체.
|
||
|
||
## Task 2.1: home.mobile.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/home.mobile.jsx`
|
||
|
||
- [ ] **Step 1: home.mobile.jsx 작성**
|
||
|
||
디자인 프로토타입의 `screen-home.jsx`(파일 미참조 — 디자인의 컬러/구조 기반 재구성) 패턴 따라 night-bg + 호령 hero + 3 ActionCard + 입력 폼:
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import TopRibbon from '../_shell/TopRibbon';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import InputRow from '../_shell/InputRow';
|
||
import { IconChevron, IconSparkle, IconSun, IconHeart, IconYinYang } from '../_shell/Icons';
|
||
import useSajuForm from '../hooks/useSajuForm';
|
||
|
||
const ACTIONS = [
|
||
{ to: '/saju/today', icon: IconSun, label: '오늘의 운세', color: '#D4AF37' },
|
||
{ to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', color: '#4E6B5C' },
|
||
{ to: '/saju/result', icon: IconYinYang, label: '사주풀이', color: '#6A4C7C' },
|
||
];
|
||
|
||
export default function HomeMobile() {
|
||
const navigate = useNavigate();
|
||
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||
|
||
return (
|
||
<main className="page night-bg screen-in" style={{ paddingTop: 24 }}>
|
||
<TopRibbon color="#D4AF37" opacity={0.7} />
|
||
<div style={{ padding: '8px 24px 0', textAlign: 'center', color: '#F7F2E8' }}>
|
||
<TitleBlock color="#F7F2E8" subColor="rgba(247,242,232,0.7)"
|
||
title="호령이 안내하는 사주"
|
||
subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다."
|
||
/>
|
||
</div>
|
||
<div style={{ padding: '24px 20px 0', display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||
<MascotBubble tone="navy" align="left"
|
||
text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'}
|
||
style={{ flex: 1, marginBottom: 8 }}
|
||
/>
|
||
<Mascot variant="full" size={140} style={{ marginRight: -8 }} />
|
||
</div>
|
||
|
||
<div style={{ padding: '24px 20px 0', display: 'grid', gap: 10 }}>
|
||
{ACTIONS.map((a) => (
|
||
<button key={a.to} onClick={() => navigate(a.to)} style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
background: 'rgba(247,242,232,0.06)', border: `1px solid ${a.color}55`,
|
||
borderRadius: 12, padding: '14px 16px', color: '#F7F2E8',
|
||
fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em',
|
||
}}>
|
||
<a.icon size={20} stroke={a.color} strokeWidth={1.8} />
|
||
<span style={{ flex: 1, textAlign: 'left' }}>{a.label}</span>
|
||
<IconChevron dir="right" size={14} color="#E8C76B" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ padding: '24px 20px 40px' }}>
|
||
<OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16}>
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="font-title" style={{
|
||
fontSize: 16, color: '#1F2A44', marginBottom: 8, textAlign: 'center',
|
||
}}>사주 입력</div>
|
||
<InputRow label="이름" name="name" value={form.name} onChange={handleChange} placeholder="홍길동" />
|
||
<InputRow label="생년월일" name="birthDate" type="date" value={form.birthDate} onChange={handleChange} />
|
||
<InputRow label="시간" name="birthTime" type="time" value={form.birthTime} onChange={handleChange} />
|
||
<InputRow label="성별">
|
||
<select name="gender" value={form.gender} onChange={handleChange}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="male">남</option>
|
||
<option value="female">여</option>
|
||
</select>
|
||
</InputRow>
|
||
<InputRow label="달력">
|
||
<select name="calendar" value={form.calendar} onChange={handleChange}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="solar">양력</option>
|
||
<option value="lunar">음력</option>
|
||
</select>
|
||
</InputRow>
|
||
{error && (
|
||
<div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div>
|
||
)}
|
||
<div style={{ padding: '14px 14px 6px' }}>
|
||
<PrimaryButton color="#6A4C7C" type="submit">
|
||
{loading ? '호령이 풀이 중...' : '내 사주 보기'}
|
||
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||
</PrimaryButton>
|
||
</div>
|
||
</form>
|
||
</OrnateFrame>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/home.mobile.jsx
|
||
git commit -m "feat(saju-ui-v2): home.mobile.jsx — night hero + ActionCard×3 + 입력 폼"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2.2: home.desktop.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/home.desktop.jsx`
|
||
|
||
- [ ] **Step 1: home.desktop.jsx 작성**
|
||
|
||
데스크탑은 mt-wash 산수화 배경 + 2-column hero (좌 호령, 우 입력):
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import InputRow from '../_shell/InputRow';
|
||
import { IconSparkle, IconChevron, IconSun, IconHeart, IconYinYang } from '../_shell/Icons';
|
||
import useSajuForm from '../hooks/useSajuForm';
|
||
|
||
const ACTIONS = [
|
||
{ to: '/saju/today', icon: IconSun, label: '오늘의 운세', desc: '오늘 한 줄로 보는 운세', color: '#D4AF37' },
|
||
{ to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', desc: '두 사람의 만남 풀이', color: '#4E6B5C' },
|
||
{ to: '/saju/result', icon: IconYinYang, label: '사주풀이', desc: '내 사주 자세히', color: '#6A4C7C' },
|
||
];
|
||
|
||
export default function HomeDesktop() {
|
||
const navigate = useNavigate();
|
||
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||
|
||
return (
|
||
<main className="page mt-wash screen-in">
|
||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '48px 32px' }}>
|
||
<TitleBlock title="호령이 안내하는 사주"
|
||
subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다." />
|
||
<div style={{
|
||
marginTop: 32, display: 'grid', gridTemplateColumns: '1fr 480px', gap: 40, alignItems: 'start',
|
||
}}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}>
|
||
<Mascot variant="full" size={260} />
|
||
<MascotBubble tone="ivory" align="left"
|
||
text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'}
|
||
style={{ marginBottom: 20 }}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 32, display: 'grid', gap: 12 }}>
|
||
{ACTIONS.map((a) => (
|
||
<button key={a.to} onClick={() => navigate(a.to)} style={{
|
||
display: 'flex', alignItems: 'center', gap: 16,
|
||
background: '#FBF7EF', border: `1px solid ${a.color}40`,
|
||
borderRadius: 12, padding: '16px 20px', color: '#1F2A44',
|
||
fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', textAlign: 'left',
|
||
boxShadow: 'var(--shadow-card)',
|
||
}}>
|
||
<a.icon size={24} stroke={a.color} strokeWidth={1.8} />
|
||
<span style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 15 }}>{a.label}</div>
|
||
<div style={{ fontSize: 12, color: '#6B6B6B', fontWeight: 500, marginTop: 2 }}>{a.desc}</div>
|
||
</span>
|
||
<IconChevron dir="right" size={16} color="#B89530" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16} padding="24px 22px">
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="font-title" style={{
|
||
fontSize: 18, color: '#1F2A44', marginBottom: 12, textAlign: 'center',
|
||
}}>사주 입력</div>
|
||
<InputRow label="이름" name="name" value={form.name} onChange={handleChange} placeholder="홍길동" />
|
||
<InputRow label="생년월일" name="birthDate" type="date" value={form.birthDate} onChange={handleChange} />
|
||
<InputRow label="시간" name="birthTime" type="time" value={form.birthTime} onChange={handleChange} />
|
||
<InputRow label="성별">
|
||
<select name="gender" value={form.gender} onChange={handleChange}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="male">남</option>
|
||
<option value="female">여</option>
|
||
</select>
|
||
</InputRow>
|
||
<InputRow label="달력">
|
||
<select name="calendar" value={form.calendar} onChange={handleChange}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="solar">양력</option>
|
||
<option value="lunar">음력</option>
|
||
</select>
|
||
</InputRow>
|
||
{error && (
|
||
<div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div>
|
||
)}
|
||
<div style={{ padding: '14px 14px 6px' }}>
|
||
<PrimaryButton color="#6A4C7C" type="submit">
|
||
{loading ? '호령이 풀이 중...' : '내 사주 보기'}
|
||
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||
</PrimaryButton>
|
||
</div>
|
||
</form>
|
||
</OrnateFrame>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/home.desktop.jsx
|
||
git commit -m "feat(saju-ui-v2): home.desktop.jsx — mt-wash 산수화 + 2-column hero"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2.3: Saju.jsx (routes 진입) 교체
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/pages/saju/Saju.jsx` (전면 교체)
|
||
|
||
- [ ] **Step 1: Saju.jsx 본문 전면 교체**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import HomeMobile from './views/home.mobile.jsx';
|
||
import HomeDesktop from './views/home.desktop.jsx';
|
||
|
||
export default function Saju() {
|
||
const mode = useViewportMode();
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' ? <DesktopHeader /> : null}
|
||
{mode === 'desktop' ? <HomeDesktop /> : <HomeMobile />}
|
||
{mode === 'mobile' ? <BottomNav theme="navy" /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: useSajuForm hook 시그니처 확인**
|
||
|
||
Run: `cat web-ui/src/pages/saju/hooks/useSajuForm.js`
|
||
필드명(form.name, birthDate, birthTime, gender, calendar) + handleChange + handleSubmit + loading + error 5종이 home에서 사용하는 시그니처와 일치하는지 확인. 다르면 home.mobile/desktop의 input name과 useSajuForm의 state 키를 맞춤 (Task 2.1/2.2 inline fix).
|
||
|
||
- [ ] **Step 3: dev server 시각 검증**
|
||
|
||
`http://localhost:3007/saju`:
|
||
- 모바일 (390×844): night-bg 어두운 hero, MascotBubble navy tone, 호령 PNG, 3 ActionCard, OrnateFrame 입력 폼, BottomNav home 활성
|
||
- 데스크탑 (1280×720): mt-wash 산수화, 2-column, DesktopHeader home 활성
|
||
- 입력 후 "내 사주 보기" submit → `/saju/result?rid=N` 이동 확인 (백엔드 API 호출)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/Saju.jsx
|
||
git commit -m "feat(saju-ui-v2): Saju.jsx — useViewportMode 분기 + Home v2 진입"
|
||
```
|
||
|
||
- [ ] **Step 5: Phase 2 종료 tag (선택)**
|
||
|
||
```bash
|
||
git tag saju-ui-v2-phase2
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 3 — SajuResult 4탭 (`/saju/result`)
|
||
|
||
## Task 3.1: saju.mobile.jsx — 탭 컨테이너 + BasicTab
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/saju.mobile.jsx`
|
||
|
||
- [ ] **Step 1: saju.mobile.jsx 골격 + BasicTab 작성**
|
||
|
||
reading 데이터를 prop으로 받고 4탭 + 첫 탭(BasicTab) 구현:
|
||
|
||
```jsx
|
||
import React, { useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import TopRibbon from '../_shell/TopRibbon';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import OrnamentBloom from '../_shell/OrnamentBloom';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import { IconChevron, IconSparkle } from '../_shell/Icons';
|
||
import deriveTraits from '../_shell/helpers/deriveTraits';
|
||
import daeunLabel from '../_shell/helpers/daeunLabel';
|
||
import { elementColor, elementKo, elementCh } from '../_shell/helpers/colorMap';
|
||
import hexA from '../_shell/helpers/hexA';
|
||
|
||
const TABS = [
|
||
['basic', '기본정보'],
|
||
['chart', '사주명식'],
|
||
['flow', '운세흐름'],
|
||
['traits', '성향분석'],
|
||
];
|
||
|
||
export default function SajuMobile({ reading }) {
|
||
const [tab, setTab] = useState('basic');
|
||
const navigate = useNavigate();
|
||
const traits = deriveTraits(reading?.analysis?.elements || {}, reading?.pillars || []);
|
||
|
||
return (
|
||
<main className="page paper-bg screen-in">
|
||
<TopRibbon color="#6A4C7C" opacity={0.6} />
|
||
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||
<TitleBlock gold="#6A4C7C" title="사주풀이"
|
||
subtitle="당신의 사주를 자세히 풀이해드립니다." />
|
||
</div>
|
||
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||
<MascotBubble tone="purple"
|
||
text={'당신이 가진 타고난\n기운과 운명의 흐름을\n알려드릴게요.'}
|
||
style={{ flex: 1, marginBottom: 8 }} />
|
||
<Mascot variant="full" size={130} style={{ marginRight: -8 }} />
|
||
</div>
|
||
|
||
<div style={{ padding: '14px 16px 0' }}>
|
||
<div className="no-scrollbar" style={{
|
||
display: 'flex', gap: 6, overflowX: 'auto',
|
||
background: 'rgba(247,242,232,0.7)', borderRadius: 999,
|
||
padding: 4, border: '1px solid rgba(31,42,68,0.08)',
|
||
}}>
|
||
{TABS.map(([id, label]) => {
|
||
const active = tab === id;
|
||
return (
|
||
<button key={id} onClick={() => setTab(id)} style={{
|
||
flex: 1, padding: '10px 8px', borderRadius: 999, border: 'none',
|
||
background: active ? '#1F2A44' : 'transparent',
|
||
color: active ? '#F7F2E8' : '#6B6B6B',
|
||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap',
|
||
boxShadow: active ? '0 2px 8px rgba(31,42,68,0.25), inset 0 1px 0 rgba(212,175,55,0.3)' : 'none',
|
||
transition: 'all .2s',
|
||
}}>{label}</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '14px 20px 0' }}>
|
||
{tab === 'basic' && <BasicTab reading={reading} traits={traits} onResult={() => setTab('chart')} />}
|
||
{tab === 'chart' && <ChartTab reading={reading} />}
|
||
{tab === 'flow' && <FlowTab reading={reading} />}
|
||
{tab === 'traits' && <TraitsTab traits={traits} onToday={() => navigate('/saju/today')} />}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function BasicTab({ reading, traits, onResult }) {
|
||
const i = reading?.input || {};
|
||
const rows = [
|
||
['이름', i.name],
|
||
['생년월일', i.birthDate],
|
||
['성별', i.gender === 'female' ? '여' : '남'],
|
||
['시간', i.birthTime],
|
||
['출생지', i.birthPlace || '-'],
|
||
['사주', reading?.label || '-'],
|
||
];
|
||
const summary = reading?.interpretation?.summary || '풀이 결과를 준비 중입니다.';
|
||
return (
|
||
<div>
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)',
|
||
boxShadow: 'var(--shadow-card)', overflow: 'hidden',
|
||
}}>
|
||
{rows.map(([label, value], idx) => (
|
||
<div key={label} style={{
|
||
display: 'flex', alignItems: 'center', padding: '13px 16px',
|
||
borderBottom: idx === rows.length - 1 ? 'none' : '1px solid rgba(31,42,68,0.06)',
|
||
}}>
|
||
<div style={{ width: 64, fontSize: 12, color: '#6B6B6B', fontWeight: 700 }}>{label}</div>
|
||
<div style={{ flex: 1, fontSize: 13, color: '#1F2A44' }}>{value || '-'}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<OrnateFrame color="#6A4C7C" bg="#FBF7EF" radius={14} padding="20px 18px 16px" style={{ marginTop: 14 }}>
|
||
<div className="font-title" style={{
|
||
fontSize: 13, color: '#6A4C7C', textAlign: 'center', marginBottom: 6,
|
||
}}>사주 요약</div>
|
||
<div style={{
|
||
fontSize: 13, color: '#1F2A44', lineHeight: 1.75, textAlign: 'center', whiteSpace: 'pre-line',
|
||
}}>{summary}</div>
|
||
</OrnateFrame>
|
||
|
||
<div style={{
|
||
marginTop: 14, background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)', padding: '16px 12px',
|
||
boxShadow: 'var(--shadow-card)',
|
||
}}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 6 }}>
|
||
{traits.slice(0, 5).map((t) => (<TraitChip key={t.id} {...t} />))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 14 }}>
|
||
<PrimaryButton color="#6A4C7C" onClick={onResult}>
|
||
상세 풀이 보러가기
|
||
<IconChevron dir="right" size={14} color="#E8C76B" />
|
||
</PrimaryButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TraitChip({ ko, color }) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, padding: '10px 4px 8px',
|
||
}}>
|
||
<div style={{
|
||
width: 42, height: 42, borderRadius: '50%',
|
||
background: hexA(color.replace('var(--', '').replace(')', '') === 'el-fire' ? '#C04A4A' : '#1F2A44', 0.10),
|
||
border: `1px solid ${color}`, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
color, fontSize: 18, fontWeight: 700,
|
||
}}>•</div>
|
||
<span style={{ fontSize: 11, color: '#1F2A44', fontWeight: 700, letterSpacing: '-0.02em' }}>{ko}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChartTab({ reading }) {
|
||
const pillars = reading?.pillars || [];
|
||
const elements = reading?.analysis?.elements || {};
|
||
const ohaengArr = ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({
|
||
id, ko: elementKo(id), ch: elementCh(id),
|
||
value: Math.round(elements[id] || 0), color: elementColor(id),
|
||
}));
|
||
const strongest = ohaengArr.reduce((a, b) => (a.value > b.value ? a : b), { value: 0 });
|
||
return (
|
||
<div>
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||
padding: '14px 12px 12px',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, padding: '0 6px' }}>
|
||
<OrnamentBloom size={14} color="#6A4C7C" />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>사주 명식</span>
|
||
<span style={{ fontSize: 10, color: '#9A968D', marginLeft: 'auto' }}>일간 중심 해석</span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
|
||
{pillars.map((p) => (<PillarColumn key={p.id || p.label} pillar={p} />))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{
|
||
marginTop: 14, background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||
padding: '16px 16px 14px',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||
<OrnamentBloom size={14} color="#6A4C7C" />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오행 분석</span>
|
||
</div>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||
height: 110, gap: 4,
|
||
}}>
|
||
{ohaengArr.map((o) => (<OhaengBar key={o.id} {...o} />))}
|
||
</div>
|
||
{strongest.value > 0 && (
|
||
<div style={{
|
||
marginTop: 12, padding: '10px 12px',
|
||
background: hexA('#C04A4A', 0.06), borderRadius: 8,
|
||
border: '1px solid rgba(192,74,74,0.18)',
|
||
}}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: strongest.color, marginBottom: 4 }}>
|
||
{strongest.ko}({strongest.ch})의 기운이 강한 사주입니다.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PillarColumn({ pillar }) {
|
||
const p = pillar;
|
||
const isDay = p.id === 'day' || p.label === '일주';
|
||
return (
|
||
<div style={{
|
||
borderRadius: 10, padding: '8px 4px 10px',
|
||
background: isDay ? '#FBF7EF' : 'transparent',
|
||
border: isDay ? '1.5px solid #6A4C7C' : '1px solid rgba(31,42,68,0.06)',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, position: 'relative',
|
||
}}>
|
||
{isDay && (
|
||
<div style={{
|
||
position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)',
|
||
background: '#6A4C7C', color: '#F7F2E8',
|
||
fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 99,
|
||
}}>일간</div>
|
||
)}
|
||
<div style={{ fontSize: 10, color: '#6B6B6B', fontWeight: 700, marginTop: 2 }}>{p.label}</div>
|
||
<CharBox char={p.cheongan?.ch} mark={p.cheongan?.mark} color={elementColor(p.cheongan?.element)} />
|
||
<CharBox char={p.jiji?.ch} mark={p.jiji?.mark} color={elementColor(p.jiji?.element)} />
|
||
<div style={{ width: '100%', height: 1, background: 'rgba(31,42,68,0.08)' }} />
|
||
<div style={{ fontSize: 10, color: '#6B6B6B' }}>{p.sipsin || '-'}</div>
|
||
<div style={{ fontSize: 10, color: '#9A968D', letterSpacing: '0.04em' }}>{p.jijang || ''}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CharBox({ char, mark, color }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||
<div className="font-title" style={{ fontSize: 24, color, lineHeight: 1, fontWeight: 800 }}>{char || '?'}</div>
|
||
<div style={{ fontSize: 8.5, color, opacity: 0.85, fontWeight: 700 }}>{mark || ''}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OhaengBar({ ko, ch, value, color }) {
|
||
return (
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
|
||
<div style={{ flex: 1, width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||
<div style={{ fontSize: 10, color, fontWeight: 700, textAlign: 'center', marginBottom: 2 }}>{value}%</div>
|
||
<div style={{
|
||
width: '70%', margin: '0 auto', height: `${value}%`, minHeight: 4,
|
||
background: color, borderRadius: '6px 6px 2px 2px',
|
||
boxShadow: `0 -2px 6px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.3)`,
|
||
}} />
|
||
</div>
|
||
<div style={{ marginTop: 6, fontSize: 11, color: '#1F2A44', fontWeight: 700 }}>
|
||
{ko}<span style={{ fontSize: 9, color: '#9A968D', marginLeft: 2 }}>({ch})</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FlowTab({ reading }) {
|
||
const daeun = reading?.daeun || [];
|
||
const currentAge = reading?.current_age;
|
||
const enriched = daeun.map((d) => {
|
||
const ageNum = parseInt(String(d.age).replace(/[^0-9]/g, ''), 10);
|
||
return {
|
||
...d,
|
||
label: daeunLabel(ageNum),
|
||
current: currentAge != null && ageNum <= currentAge && currentAge < ageNum + 10,
|
||
};
|
||
});
|
||
const current = enriched.find((x) => x.current);
|
||
return (
|
||
<div>
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||
padding: '16px 14px',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||
<OrnamentBloom size={14} color="#6A4C7C" />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>대운 흐름</span>
|
||
<span style={{ marginLeft: 'auto', fontSize: 10, color: '#9A968D' }}>10년 단위</span>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: '#6B6B6B', marginBottom: 12 }}>
|
||
10년 주기로 변화하는 운의 흐름을 확인하세요.
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||
{enriched.map((du, i) => (<DaeunNode key={i} {...du} />))}
|
||
</div>
|
||
</div>
|
||
{current && (
|
||
<div style={{
|
||
marginTop: 14, background: '#1F2A44', borderRadius: 14,
|
||
border: '1px solid rgba(212,175,55,0.4)',
|
||
padding: '16px 16px 18px', color: '#F7F2E8',
|
||
boxShadow: '0 8px 24px rgba(31,42,68,0.2)', position: 'relative',
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute', top: -10, left: 16,
|
||
background: '#6A4C7C', color: '#F7F2E8',
|
||
fontSize: 10, fontWeight: 700, padding: '3px 10px',
|
||
borderRadius: 99, border: '1px solid rgba(212,175,55,0.5)',
|
||
}}>현재 대운 · {current.age}</div>
|
||
<div className="font-title" style={{ marginTop: 8, fontSize: 18, color: '#E8C76B' }}>
|
||
{current.gan} · {current.label}
|
||
</div>
|
||
<div style={{ marginTop: 10, fontSize: 12.5, color: '#D9D2C0', lineHeight: 1.7 }}>
|
||
{reading?.interpretation?.current_daeun || '대운 풀이를 준비 중입니다.'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DaeunNode({ age, gan, label, current }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, position: 'relative' }}>
|
||
{current && (
|
||
<div style={{
|
||
position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)',
|
||
fontSize: 8, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
|
||
padding: '1px 6px', borderRadius: 99, zIndex: 1,
|
||
}}>현재</div>
|
||
)}
|
||
<div style={{ fontSize: 9.5, color: '#9A968D', marginTop: current ? 8 : 0, fontWeight: 700 }}>{age}</div>
|
||
<div style={{
|
||
width: 42, height: 50, borderRadius: '50% 50% 40% 40%',
|
||
background: current ? '#6A4C7C' : '#FBF7EF',
|
||
border: current ? '1.5px solid #D4AF37' : '1px solid rgba(31,42,68,0.12)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: current ? '0 4px 12px rgba(106,76,124,0.4)' : 'none',
|
||
}}>
|
||
<span className="font-title" style={{ fontSize: 20, color: current ? '#E8C76B' : '#1F2A44' }}>{gan}</span>
|
||
</div>
|
||
<div style={{ fontSize: 10, color: current ? '#6A4C7C' : '#6B6B6B', fontWeight: current ? 700 : 500 }}>{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TraitsTab({ traits, onToday }) {
|
||
return (
|
||
<div>
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 14,
|
||
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||
padding: '16px 12px',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12, padding: '0 6px' }}>
|
||
<OrnamentBloom size={14} color="#6A4C7C" />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>타고난 성향</span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||
{traits.map((t) => (<TraitChip key={t.id} {...t} />))}
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: 14 }}>
|
||
<PrimaryButton color="#6A4C7C" onClick={onToday}>
|
||
오늘의 운세 확인하기
|
||
<IconSparkle size={12} color="#E8C76B" />
|
||
</PrimaryButton>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/saju.mobile.jsx
|
||
git commit -m "feat(saju-ui-v2): saju.mobile.jsx — 4탭 (Basic/Chart/Flow/Traits) + 실 데이터 매핑"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3.2: saju.desktop.jsx — 모바일 4탭 구조 유지 + 폭만 확장
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/saju.desktop.jsx`
|
||
|
||
- [ ] **Step 1: saju.desktop.jsx 작성 — Task 3.1의 SajuMobile를 max-width 900 컨테이너 + hero gap 확대로 wrap**
|
||
|
||
데스크탑은 4탭 그대로 유지 (Spec 12절 결정 → 단순한 1-column으로 충분). 진입 컨테이너만 다름:
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import SajuMobile from './saju.mobile.jsx';
|
||
|
||
export default function SajuDesktop({ reading }) {
|
||
return (
|
||
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||
<SajuMobile reading={reading} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/saju.desktop.jsx
|
||
git commit -m "feat(saju-ui-v2): saju.desktop.jsx — 4탭 유지 + max-width 900 컨테이너"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3.3: SajuResult.jsx 라우트 진입 교체
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/pages/saju/SajuResult.jsx` (전면 교체)
|
||
|
||
- [ ] **Step 1: SajuResult.jsx 전면 교체**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useSearchParams, Link } from 'react-router-dom';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import useSajuReading from './hooks/useSajuReading';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import Mascot from './_shell/Mascot';
|
||
import MascotBubble from './_shell/MascotBubble';
|
||
import PrimaryButton from './_shell/PrimaryButton';
|
||
import GhostButton from './_shell/GhostButton';
|
||
import SajuMobile from './views/saju.mobile.jsx';
|
||
import SajuDesktop from './views/saju.desktop.jsx';
|
||
|
||
export default function SajuResult() {
|
||
const mode = useViewportMode();
|
||
const [params] = useSearchParams();
|
||
const rid = params.get('rid');
|
||
const { reading, loading, error, reload } = useSajuReading(rid);
|
||
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' && <DesktopHeader />}
|
||
{!rid && <EmptyState />}
|
||
{rid && loading && <LoadingState />}
|
||
{rid && error && <ErrorState onRetry={reload} />}
|
||
{rid && reading && (mode === 'desktop'
|
||
? <SajuDesktop reading={reading} />
|
||
: <SajuMobile reading={reading} />
|
||
)}
|
||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState() {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="ivory" tail={false}
|
||
text="사주를 먼저 입력해주세요."
|
||
style={{ margin: '0 auto 24px' }} />
|
||
<Link to="/saju" style={{ display: 'inline-block' }}>
|
||
<PrimaryButton color="#6A4C7C" full={false}>사주 입력하러 가기</PrimaryButton>
|
||
</Link>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function LoadingState() {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="purple" tail={false}
|
||
text={'호령이 풀이 중이에요...\n(최대 1분 정도 걸려요)'}
|
||
style={{ margin: '0 auto' }} />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function ErrorState({ onRetry }) {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="purple" tail={false}
|
||
text="아이고, 풀이를 가져오지 못했어요. 다시 시도해주세요."
|
||
style={{ margin: '0 auto 20px' }} />
|
||
<GhostButton color="#6A4C7C" full={false} onClick={onRetry}>다시 시도</GhostButton>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: useSajuReading 시그니처 확인**
|
||
|
||
Run: `cat web-ui/src/pages/saju/hooks/useSajuReading.js`
|
||
반환 객체에 `reading`, `loading`, `error`, `reload`(없으면 `refetch` 등 다른 이름) 키 확인. 다르면 SajuResult.jsx의 destructure를 hook 시그니처에 맞춤 (inline fix).
|
||
|
||
- [ ] **Step 3: dev server 시각 검증**
|
||
|
||
- `/saju/result` (rid 없음) → EmptyState + greeting Mascot
|
||
- `/saju/result?rid=<유효>` → 4탭 정상 (모바일 + 데스크탑)
|
||
- 잘못된 rid → ErrorState
|
||
- 4탭 전환 시 basic/chart(8자 + 오행)/flow(대운)/traits 모두 데이터 표시
|
||
- 호령 PNG 모두 로드 (Network 탭)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/SajuResult.jsx
|
||
git commit -m "feat(saju-ui-v2): SajuResult.jsx v2 진입 + Empty/Loading/Error state"
|
||
```
|
||
|
||
- [ ] **Step 5: Phase 3 종료 tag**
|
||
|
||
```bash
|
||
git tag saju-ui-v2-phase3
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 4 — Today (`/saju/today`)
|
||
|
||
## Task 4.1: today.mobile.jsx + today.desktop.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/today.mobile.jsx`
|
||
- Create: `web-ui/src/pages/saju/views/today.desktop.jsx`
|
||
|
||
- [ ] **Step 1: today.mobile.jsx 작성 — FortuneRing + 4 ScoreCard + LuckyBox**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import TopRibbon from '../_shell/TopRibbon';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import OrnamentBloom from '../_shell/OrnamentBloom';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import { IconChevron, IconSparkle } from '../_shell/Icons';
|
||
|
||
const SCORE_LABELS = {
|
||
wealth: { ko: '재물운', icon: '금' },
|
||
love: { ko: '연애운', icon: '연' },
|
||
health: { ko: '건강운', icon: '생' },
|
||
work: { ko: '직장운', icon: '업' },
|
||
};
|
||
|
||
export default function TodayMobile({ reading, fortune }) {
|
||
const navigate = useNavigate();
|
||
const scores = fortune?.scores || {};
|
||
const lucky = fortune?.lucky || {};
|
||
const signs = fortune?.good_signs || [];
|
||
const warnings = fortune?.warnings || [];
|
||
const overall = Math.round(fortune?.overall_score || 0);
|
||
|
||
return (
|
||
<main className="page paper-bg screen-in">
|
||
<TopRibbon color="#D4AF37" opacity={0.7} />
|
||
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||
<TitleBlock title="오늘의 운세" gold="#D4AF37"
|
||
subtitle={fortune?.date || '오늘의 흐름을 확인하세요.'}
|
||
/>
|
||
</div>
|
||
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||
<MascotBubble tone="ivory"
|
||
text={fortune?.greeting || '오늘 하루도 좋은 흐름이 있어요.'}
|
||
style={{ flex: 1, marginBottom: 8 }} />
|
||
<Mascot variant="happy" size={130} style={{ marginRight: -8 }} />
|
||
</div>
|
||
|
||
<div style={{ padding: '20px', display: 'flex', justifyContent: 'center' }}>
|
||
<FortuneRing value={overall} />
|
||
</div>
|
||
|
||
<div style={{ padding: '0 20px', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
||
{Object.entries(SCORE_LABELS).map(([key, { ko, icon }]) => (
|
||
<ScoreCard key={key} ko={ko} icon={icon} value={Math.round(scores[key] || 0)} />
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ padding: '20px' }}>
|
||
<OrnateFrame color="#D4AF37" bg="#FBF7EF" radius={14} padding="16px 18px" double>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||
<OrnamentBloom size={14} color="#D4AF37" />
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오늘의 럭키</span>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||
<LuckyItem label="색" value={lucky.color} />
|
||
<LuckyItem label="숫자" value={lucky.number} />
|
||
<LuckyItem label="방향" value={lucky.direction} />
|
||
</div>
|
||
</OrnateFrame>
|
||
</div>
|
||
|
||
{(signs.length > 0 || warnings.length > 0) && (
|
||
<div style={{ padding: '0 20px 20px', display: 'grid', gap: 12 }}>
|
||
{signs.length > 0 && (
|
||
<SignList title="좋은 징조" items={signs} color="#4E6B5C" />
|
||
)}
|
||
{warnings.length > 0 && (
|
||
<SignList title="주의할 점" items={warnings} color="#C04A4A" />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ padding: '0 20px 40px' }}>
|
||
<PrimaryButton color="#D4AF37" onClick={() => navigate('/saju/result?rid=' + (reading?.id || ''))}>
|
||
내 사주 자세히 보기 <IconChevron dir="right" size={14} color="#1F2A44" />
|
||
</PrimaryButton>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function FortuneRing({ value }) {
|
||
const R = 60;
|
||
const C = 2 * Math.PI * R;
|
||
const offset = C - (C * value) / 100;
|
||
return (
|
||
<svg width="160" height="160" viewBox="0 0 160 160">
|
||
<circle cx="80" cy="80" r={R} stroke="#F0E9D9" strokeWidth="14" fill="none" />
|
||
<circle cx="80" cy="80" r={R} stroke="#D4AF37" strokeWidth="14" fill="none"
|
||
strokeDasharray={C} strokeDashoffset={offset} strokeLinecap="round"
|
||
transform="rotate(-90 80 80)" />
|
||
<text x="80" y="86" textAnchor="middle" className="font-title"
|
||
style={{ fontSize: 32, fill: '#1F2A44', fontWeight: 800 }}>
|
||
{value}<tspan style={{ fontSize: 14, fill: '#9A968D' }}>점</tspan>
|
||
</text>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ScoreCard({ ko, icon, value }) {
|
||
return (
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 12,
|
||
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
||
}}>
|
||
<div style={{
|
||
width: 36, height: 36, borderRadius: '50%',
|
||
background: 'rgba(212,175,55,0.12)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 14, fontWeight: 800, color: '#B89530',
|
||
}}>{icon}</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{ko}</div>
|
||
<div className="font-title" style={{ fontSize: 20, color: '#1F2A44' }}>
|
||
{value}<span style={{ fontSize: 12, color: '#9A968D', fontWeight: 500 }}>점</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LuckyItem({ label, value }) {
|
||
return (
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{label}</div>
|
||
<div className="font-title" style={{ fontSize: 18, color: '#D4AF37', marginTop: 4 }}>{value || '-'}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SignList({ title, items, color }) {
|
||
return (
|
||
<div style={{
|
||
background: '#FBF7EF', borderRadius: 12,
|
||
border: `1px solid ${color}40`, padding: '14px 16px',
|
||
}}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color, marginBottom: 8 }}>{title}</div>
|
||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: 6 }}>
|
||
{items.map((s, i) => (
|
||
<li key={i} style={{ fontSize: 12.5, color: '#1F2A44', lineHeight: 1.6 }}>• {s}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: today.desktop.jsx 작성 (모바일 wrap + 폭만 확장)**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import TodayMobile from './today.mobile.jsx';
|
||
|
||
export default function TodayDesktop({ reading, fortune }) {
|
||
return (
|
||
<div style={{ maxWidth: 720, margin: '0 auto' }}>
|
||
<TodayMobile reading={reading} fortune={fortune} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/today.mobile.jsx web-ui/src/pages/saju/views/today.desktop.jsx
|
||
git commit -m "feat(saju-ui-v2): today.mobile/desktop.jsx — FortuneRing + 4 ScoreCard + LuckyBox + signs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4.2: Today.jsx 라우트 진입 교체
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/pages/saju/Today.jsx` (전면 교체)
|
||
|
||
- [ ] **Step 1: Today.jsx 전면 교체**
|
||
|
||
```jsx
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useSearchParams, Link } from 'react-router-dom';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import useSajuReading from './hooks/useSajuReading';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import Mascot from './_shell/Mascot';
|
||
import MascotBubble from './_shell/MascotBubble';
|
||
import PrimaryButton from './_shell/PrimaryButton';
|
||
import GhostButton from './_shell/GhostButton';
|
||
import TodayMobile from './views/today.mobile.jsx';
|
||
import TodayDesktop from './views/today.desktop.jsx';
|
||
import { sajuGetCurrentFortune } from '../../api';
|
||
|
||
export default function Today() {
|
||
const mode = useViewportMode();
|
||
const [params] = useSearchParams();
|
||
const rid = params.get('rid');
|
||
const { reading, loading: readingLoading, error: readingError } = useSajuReading(rid);
|
||
const [fortune, setFortune] = useState(null);
|
||
const [fortuneError, setFortuneError] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!rid) return;
|
||
sajuGetCurrentFortune(rid).then(setFortune).catch(setFortuneError);
|
||
}, [rid]);
|
||
|
||
const loading = readingLoading || (rid && !fortune && !fortuneError);
|
||
const error = readingError || fortuneError;
|
||
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' && <DesktopHeader />}
|
||
{!rid && <EmptyState />}
|
||
{rid && loading && <LoadingState />}
|
||
{rid && error && <ErrorState onRetry={() => window.location.reload()} />}
|
||
{rid && reading && fortune && (mode === 'desktop'
|
||
? <TodayDesktop reading={reading} fortune={fortune} />
|
||
: <TodayMobile reading={reading} fortune={fortune} />
|
||
)}
|
||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState() {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="ivory" tail={false}
|
||
text="사주를 먼저 입력해주세요."
|
||
style={{ margin: '0 auto 24px' }} />
|
||
<Link to="/saju" style={{ display: 'inline-block' }}>
|
||
<PrimaryButton color="#D4AF37" full={false}>사주 입력하러 가기</PrimaryButton>
|
||
</Link>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function LoadingState() {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="ivory" tail={false}
|
||
text="호령이 오늘 운세를 살펴보고 있어요..."
|
||
style={{ margin: '0 auto' }} />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function ErrorState({ onRetry }) {
|
||
return (
|
||
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="ivory" tail={false}
|
||
text="오늘 운세를 가져오지 못했어요."
|
||
style={{ margin: '0 auto 20px' }} />
|
||
<GhostButton color="#D4AF37" full={false} onClick={onRetry}>다시 시도</GhostButton>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: api.js 함수명 확인**
|
||
|
||
Run: `grep -n 'sajuGetCurrentFortune\|currentFortune' web-ui/src/api.js`
|
||
실제 export 이름이 다르면 import 수정.
|
||
|
||
- [ ] **Step 3: dev server 시각 검증**
|
||
|
||
`/saju/today?rid=<유효>` → FortuneRing, 4 ScoreCard, LuckyBox, signs 표시 + BottomNav today 활성. 모바일 + 데스크탑.
|
||
|
||
- [ ] **Step 4: Commit + tag**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/Today.jsx
|
||
git commit -m "feat(saju-ui-v2): Today.jsx v2 진입 + current-fortune fetch + state machine"
|
||
git tag saju-ui-v2-phase4
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 5 — Compatibility (`/saju/compatibility`)
|
||
|
||
기존 placeholder를 본격 구현으로 교체.
|
||
|
||
## Task 5.1: match.mobile.jsx + match.desktop.jsx
|
||
|
||
**Files:**
|
||
- Create: `web-ui/src/pages/saju/views/match.mobile.jsx`
|
||
- Create: `web-ui/src/pages/saju/views/match.desktop.jsx`
|
||
|
||
- [ ] **Step 1: match.mobile.jsx 작성**
|
||
|
||
두 사람 입력 폼 (사람 A, 사람 B):
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import TopRibbon from '../_shell/TopRibbon';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import InputRow from '../_shell/InputRow';
|
||
import { IconHeart, IconSparkle } from '../_shell/Icons';
|
||
|
||
function PersonForm({ label, person, onChange }) {
|
||
return (
|
||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="14px 16px">
|
||
<div className="font-title" style={{
|
||
fontSize: 14, color: '#4E6B5C', textAlign: 'center', marginBottom: 8,
|
||
}}>{label}</div>
|
||
<InputRow label="이름" name={`${label}-name`}
|
||
value={person.name}
|
||
onChange={(e) => onChange({ ...person, name: e.target.value })}
|
||
placeholder="홍길동" />
|
||
<InputRow label="생년월일" name={`${label}-birthDate`} type="date"
|
||
value={person.birthDate}
|
||
onChange={(e) => onChange({ ...person, birthDate: e.target.value })} />
|
||
<InputRow label="시간" name={`${label}-birthTime`} type="time"
|
||
value={person.birthTime}
|
||
onChange={(e) => onChange({ ...person, birthTime: e.target.value })} />
|
||
<InputRow label="성별">
|
||
<select value={person.gender}
|
||
onChange={(e) => onChange({ ...person, gender: e.target.value })}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="male">남</option>
|
||
<option value="female">여</option>
|
||
</select>
|
||
</InputRow>
|
||
</OrnateFrame>
|
||
);
|
||
}
|
||
|
||
export default function MatchMobile({ personA, personB, onChangeA, onChangeB, onSubmit, loading, error }) {
|
||
return (
|
||
<main className="page paper-bg screen-in">
|
||
<TopRibbon color="#4E6B5C" opacity={0.6} />
|
||
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||
<TitleBlock title="궁합 보기" gold="#4E6B5C"
|
||
subtitle="두 사람의 사주를 입력하면 만남의 흐름을 알려드려요." />
|
||
</div>
|
||
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||
<MascotBubble tone="green"
|
||
text={'두 사주를 비교해\n어울리는 결을\n읽어드릴게요.'}
|
||
style={{ flex: 1, marginBottom: 8 }} />
|
||
<Mascot variant="upper" size={120} style={{ marginRight: -8 }} />
|
||
</div>
|
||
<form onSubmit={onSubmit} style={{ padding: '24px 20px 40px', display: 'grid', gap: 14 }}>
|
||
<PersonForm label="사람 A" person={personA} onChange={onChangeA} />
|
||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||
<IconHeart size={28} stroke="#4E6B5C" strokeWidth={2} />
|
||
</div>
|
||
<PersonForm label="사람 B" person={personB} onChange={onChangeB} />
|
||
{error && (
|
||
<div style={{ color: '#C04A4A', fontSize: 12, textAlign: 'center' }}>{error}</div>
|
||
)}
|
||
<PrimaryButton color="#4E6B5C" type="submit">
|
||
{loading ? '호령이 두 사주를 비교 중...' : '궁합 보기'}
|
||
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||
</PrimaryButton>
|
||
</form>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: match.desktop.jsx 작성 — 2-column 입력**
|
||
|
||
```jsx
|
||
import React from 'react';
|
||
import TitleBlock from '../_shell/TitleBlock';
|
||
import Mascot from '../_shell/Mascot';
|
||
import MascotBubble from '../_shell/MascotBubble';
|
||
import OrnateFrame from '../_shell/OrnateFrame';
|
||
import PrimaryButton from '../_shell/PrimaryButton';
|
||
import InputRow from '../_shell/InputRow';
|
||
import { IconHeart, IconSparkle } from '../_shell/Icons';
|
||
|
||
function PersonForm({ label, person, onChange }) {
|
||
return (
|
||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px">
|
||
<div className="font-title" style={{
|
||
fontSize: 16, color: '#4E6B5C', textAlign: 'center', marginBottom: 10,
|
||
}}>{label}</div>
|
||
<InputRow label="이름" name={`${label}-name`}
|
||
value={person.name}
|
||
onChange={(e) => onChange({ ...person, name: e.target.value })}
|
||
placeholder="홍길동" />
|
||
<InputRow label="생년월일" name={`${label}-birthDate`} type="date"
|
||
value={person.birthDate}
|
||
onChange={(e) => onChange({ ...person, birthDate: e.target.value })} />
|
||
<InputRow label="시간" name={`${label}-birthTime`} type="time"
|
||
value={person.birthTime}
|
||
onChange={(e) => onChange({ ...person, birthTime: e.target.value })} />
|
||
<InputRow label="성별">
|
||
<select value={person.gender}
|
||
onChange={(e) => onChange({ ...person, gender: e.target.value })}
|
||
style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}>
|
||
<option value="male">남</option>
|
||
<option value="female">여</option>
|
||
</select>
|
||
</InputRow>
|
||
</OrnateFrame>
|
||
);
|
||
}
|
||
|
||
export default function MatchDesktop({ personA, personB, onChangeA, onChangeB, onSubmit, loading, error }) {
|
||
return (
|
||
<main className="page mt-wash screen-in">
|
||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '48px 32px' }}>
|
||
<TitleBlock title="궁합 보기" gold="#4E6B5C"
|
||
subtitle="두 사람의 사주를 입력하면 만남의 흐름을 알려드려요." />
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, marginTop: 24 }}>
|
||
<Mascot variant="upper" size={180} />
|
||
<MascotBubble tone="green"
|
||
text={'두 사주를 비교해\n어울리는 결을 읽어드릴게요.'}
|
||
style={{ marginBottom: 20 }} />
|
||
</div>
|
||
<form onSubmit={onSubmit} style={{
|
||
marginTop: 28, display: 'grid', gridTemplateColumns: '1fr 60px 1fr', gap: 16, alignItems: 'start',
|
||
}}>
|
||
<PersonForm label="사람 A" person={personA} onChange={onChangeA} />
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<IconHeart size={36} stroke="#4E6B5C" strokeWidth={2} />
|
||
</div>
|
||
<PersonForm label="사람 B" person={personB} onChange={onChangeB} />
|
||
</form>
|
||
{error && (
|
||
<div style={{ color: '#C04A4A', fontSize: 13, textAlign: 'center', marginTop: 16 }}>{error}</div>
|
||
)}
|
||
<div style={{ marginTop: 24, maxWidth: 320, margin: '24px auto 0' }}>
|
||
<PrimaryButton color="#4E6B5C" onClick={onSubmit}>
|
||
{loading ? '호령이 두 사주를 비교 중...' : '궁합 보기'}
|
||
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||
</PrimaryButton>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/views/match.mobile.jsx web-ui/src/pages/saju/views/match.desktop.jsx
|
||
git commit -m "feat(saju-ui-v2): match.mobile/desktop.jsx — 두 사람 입력 폼 (PersonForm + IconHeart)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5.2: Compatibility.jsx 라우트 진입 교체
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/pages/saju/Compatibility.jsx` (전면 교체)
|
||
|
||
- [ ] **Step 1: Compatibility.jsx 전면 교체**
|
||
|
||
```jsx
|
||
import React, { useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import MatchMobile from './views/match.mobile.jsx';
|
||
import MatchDesktop from './views/match.desktop.jsx';
|
||
import { sajuCompatInterpret } from '../../api';
|
||
|
||
const EMPTY_PERSON = { name: '', birthDate: '', birthTime: '', gender: 'male', calendar: 'solar' };
|
||
|
||
export default function Compatibility() {
|
||
const mode = useViewportMode();
|
||
const navigate = useNavigate();
|
||
const [personA, setPersonA] = useState({ ...EMPTY_PERSON });
|
||
const [personB, setPersonB] = useState({ ...EMPTY_PERSON });
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
|
||
const handleSubmit = async (e) => {
|
||
if (e && e.preventDefault) e.preventDefault();
|
||
setError(null);
|
||
if (!personA.name || !personA.birthDate || !personB.name || !personB.birthDate) {
|
||
setError('두 사람의 이름과 생년월일을 입력해주세요.');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const res = await sajuCompatInterpret({ person_a: personA, person_b: personB });
|
||
navigate(`/saju/compatibility/result?cid=${res.compat_id}`);
|
||
} catch (err) {
|
||
setError(err?.message || '궁합 풀이에 실패했어요.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const props = {
|
||
personA, personB, onChangeA: setPersonA, onChangeB: setPersonB,
|
||
onSubmit: handleSubmit, loading, error,
|
||
};
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' && <DesktopHeader />}
|
||
{mode === 'desktop' ? <MatchDesktop {...props} /> : <MatchMobile {...props} />}
|
||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: sajuCompatInterpret 시그니처/응답 확인**
|
||
|
||
Run: `grep -n 'sajuCompatInterpret\|compat/interpret' web-ui/src/api.js`
|
||
응답 키가 `compat_id`/`id` 무엇인지 확인 후 navigate URL 맞춤. 양쪽 잘못된 키면 console 에러로 즉시 발견.
|
||
|
||
- [ ] **Step 3: dev server 시각 검증**
|
||
|
||
`/saju/compatibility` → 두 사람 폼 + IconHeart, submit → `/saju/compatibility/result?cid=N` 이동. 폼 검증 (이름/생년월일 빈 상태) → error 메시지.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/Compatibility.jsx
|
||
git commit -m "feat(saju-ui-v2): Compatibility.jsx — placeholder → 두 사람 입력 폼 + compat API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5.3: CompatibilityResult.jsx 라이트 리스타일
|
||
|
||
**Files:**
|
||
- Modify: `web-ui/src/pages/saju/CompatibilityResult.jsx` (전면 교체, 결과 스키마는 기존 hook/Helper 사용)
|
||
|
||
- [ ] **Step 1: CompatibilityResult.jsx 전면 교체**
|
||
|
||
```jsx
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useSearchParams, Link } from 'react-router-dom';
|
||
import './_shell/tokens.css';
|
||
import './_shell/shell.css';
|
||
import useViewportMode from './_shell/useViewportMode';
|
||
import BottomNav from './_shell/BottomNav';
|
||
import DesktopHeader from './_shell/DesktopHeader';
|
||
import TopRibbon from './_shell/TopRibbon';
|
||
import TitleBlock from './_shell/TitleBlock';
|
||
import Mascot from './_shell/Mascot';
|
||
import MascotBubble from './_shell/MascotBubble';
|
||
import OrnateFrame from './_shell/OrnateFrame';
|
||
import PrimaryButton from './_shell/PrimaryButton';
|
||
import GhostButton from './_shell/GhostButton';
|
||
import { sajuGetCompatReading } from '../../api';
|
||
|
||
export default function CompatibilityResult() {
|
||
const mode = useViewportMode();
|
||
const [params] = useSearchParams();
|
||
const cid = params.get('cid');
|
||
const [result, setResult] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!cid) return;
|
||
sajuGetCompatReading(cid).then(setResult).catch(setError);
|
||
}, [cid]);
|
||
|
||
return (
|
||
<div className="saju-v2">
|
||
{mode === 'desktop' && <DesktopHeader />}
|
||
<main className="page paper-bg screen-in">
|
||
<TopRibbon color="#4E6B5C" opacity={0.6} />
|
||
<div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px' }}>
|
||
{!cid && (
|
||
<>
|
||
<TitleBlock title="궁합 결과" gold="#4E6B5C" />
|
||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||
<MascotBubble tone="green" tail={false} text="궁합을 먼저 보세요." style={{ margin: '0 auto 20px' }} />
|
||
<Link to="/saju/compatibility"><PrimaryButton color="#4E6B5C" full={false}>궁합 입력하러 가기</PrimaryButton></Link>
|
||
</div>
|
||
</>
|
||
)}
|
||
{cid && !result && !error && (
|
||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||
<MascotBubble tone="green" tail={false} text="호령이 두 사주를 비교 중이에요..." style={{ margin: '0 auto' }} />
|
||
</div>
|
||
)}
|
||
{cid && error && (
|
||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||
<MascotBubble tone="green" tail={false} text="궁합 결과를 가져오지 못했어요." style={{ margin: '0 auto 20px' }} />
|
||
<GhostButton color="#4E6B5C" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton>
|
||
</div>
|
||
)}
|
||
{result && (
|
||
<>
|
||
<TitleBlock title="궁합 결과" gold="#4E6B5C"
|
||
subtitle={`${result.person_a?.name} × ${result.person_b?.name}`} />
|
||
<div style={{ marginTop: 20, textAlign: 'center' }}>
|
||
<div className="font-title" style={{ fontSize: 48, color: '#4E6B5C' }}>
|
||
{result.score}<span style={{ fontSize: 18, color: '#9A968D', fontWeight: 500 }}>점</span>
|
||
</div>
|
||
</div>
|
||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 20 }}>
|
||
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', textAlign: 'center', marginBottom: 6 }}>요약</div>
|
||
<div style={{ fontSize: 13, color: '#1F2A44', lineHeight: 1.7, whiteSpace: 'pre-line' }}>
|
||
{result.interpretation?.summary || '요약을 준비 중입니다.'}
|
||
</div>
|
||
</OrnateFrame>
|
||
{result.interpretation?.strengths?.length > 0 && (
|
||
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', marginBottom: 8 }}>강점</div>
|
||
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||
{result.interpretation.strengths.map((s, i) => (<li key={i}>{s}</li>))}
|
||
</ul>
|
||
</OrnateFrame>
|
||
)}
|
||
{result.interpretation?.challenges?.length > 0 && (
|
||
<OrnateFrame color="#C04A4A" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||
<div className="font-title" style={{ fontSize: 13, color: '#C04A4A', marginBottom: 8 }}>주의할 점</div>
|
||
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||
{result.interpretation.challenges.map((s, i) => (<li key={i}>{s}</li>))}
|
||
</ul>
|
||
</OrnateFrame>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: dev server 시각 검증**
|
||
|
||
`/saju/compatibility/result?cid=<유효>` → 점수 + 요약 + 강점 + 주의할 점 모두 표시.
|
||
|
||
- [ ] **Step 3: Commit + tag**
|
||
|
||
```bash
|
||
git add web-ui/src/pages/saju/CompatibilityResult.jsx
|
||
git commit -m "feat(saju-ui-v2): CompatibilityResult.jsx v2 — 점수 + 요약 + strengths/challenges"
|
||
git tag saju-ui-v2-phase5
|
||
```
|
||
|
||
---
|
||
|
||
# Phase 6 — QA + cleanup
|
||
|
||
## Task 6.1: 골든 패스 시각 QA
|
||
|
||
- [ ] **Step 1: 5 라우트 모두 진입 확인 (모바일 + 데스크탑)**
|
||
|
||
`npm run dev` → chrome devtools:
|
||
- 390×844 (iPhone 12): `/saju` → 입력 → `/saju/result?rid=N` → 4탭 → "오늘의 운세 확인하기" → `/saju/today?rid=N` → "내 사주 자세히 보기" → 다시 `/saju/result` → BottomNav `/saju/compatibility` → 입력 → `/saju/compatibility/result?cid=N` → BottomNav `/saju/me`
|
||
- 1280×720: 동일 흐름, DesktopHeader 표시 확인
|
||
- 1024px 경계: 1023↔1024px에서 모드 전환
|
||
|
||
- [ ] **Step 2: 호령 PNG 7 variant 로드 확인**
|
||
|
||
Network 탭에서 `horyung-main/bust/front/greeting/thinking/pointing/happy.png` 모두 200 응답. 일부 페이지에서 lazy 로드되어 진입 후에야 로드되어도 OK.
|
||
|
||
- [ ] **Step 3: 폰트 + 토큰 적용 확인**
|
||
|
||
DevTools Computed → 임의 텍스트 노드에서 `font-family: 'Nanum Gothic'` (body) / `'Nanum Myeongjo'` (h1) 적용 확인. tokens.css의 `--navy/--gold` CSS 변수가 inspector에서 보임.
|
||
|
||
- [ ] **Step 4: 회귀 — saju-lab API 호출 무변경 확인**
|
||
|
||
Network 탭에서 v1 시절과 동일한 엔드포인트(POST `/api/saju/interpret`, GET `/api/saju/readings/{id}`, GET `/api/saju/current-fortune`, POST `/api/saju/compat/interpret`, GET `/api/saju/compat/readings/{id}`)만 호출됨을 확인.
|
||
|
||
---
|
||
|
||
## Task 6.2: v1 cleanup — components/ 삭제 + Saju.css 삭제 + SajuNav import 제거
|
||
|
||
**Files:**
|
||
- Delete: `web-ui/src/pages/saju/components/` (12 파일)
|
||
- Delete: `web-ui/src/pages/saju/Saju.css`
|
||
|
||
- [ ] **Step 1: dead import 사전 확인**
|
||
|
||
Run: `cd web-ui && npm run build 2>&1 | head -50`
|
||
빌드가 성공한 상태인지 확인. 실패하면 import 오류 fix 후 진행.
|
||
|
||
- [ ] **Step 2: 디렉토리 삭제**
|
||
|
||
```bash
|
||
cd /c/Users/jaeoh/Desktop/workspace/web-ui
|
||
rm -rf src/pages/saju/components
|
||
rm src/pages/saju/Saju.css
|
||
```
|
||
|
||
- [ ] **Step 3: 빌드 재실행으로 dead import 없음 확인**
|
||
|
||
Run: `npm run build`
|
||
Expected: exit 0, 에러 없음. 에러 발생 시 grep 으로 v1 파일을 import하는 곳 찾아 제거.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A web-ui/src/pages/saju/
|
||
git commit -m "chore(saju-ui-v2): v1 components/ + Saju.css 일괄 삭제 (Phase 6 cleanup)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6.3: 회귀 vitest 전체 실행
|
||
|
||
- [ ] **Step 1: vitest 전체 실행**
|
||
|
||
Run: `cd web-ui && npx vitest run 2>&1 | tail -20`
|
||
Expected: 모든 테스트 통과 (Phase 1~5에 추가한 useViewportMode/helpers/Mascot 테스트 포함, 기존 web-ui 다른 페이지 테스트도 무손상)
|
||
|
||
- [ ] **Step 2: 빌드 최종 확인**
|
||
|
||
Run: `npm run build`
|
||
Expected: exit 0, dist/ 산출물 생성. bundle size 폭증 없음 (이전 대비 ±10% 이내 — _shell/이 lazy chunk로 분리됨)
|
||
|
||
---
|
||
|
||
## Task 6.4: Phase 6 종료 + push 준비
|
||
|
||
- [ ] **Step 1: git log 검토**
|
||
|
||
Run: `git log --oneline saju-ui-v2-phase1..HEAD` (또는 분기 시작부터)
|
||
~30 커밋이 phase별로 묶여 있음을 확인.
|
||
|
||
- [ ] **Step 2: tag**
|
||
|
||
```bash
|
||
git tag saju-ui-v2-phase6
|
||
```
|
||
|
||
- [ ] **Step 3: push 안내**
|
||
|
||
사용자에게 push 권한 안내:
|
||
```
|
||
! cd web-ui && git push origin main
|
||
```
|
||
push 시 `web-ui`는 GitHub Actions / 로컬 빌드 후 NAS frontend/로 robocopy 필요 (workspace CLAUDE.md `scripts/deploy.bat --frontend` 참고). git push만으로 NAS 배포 안 됨 — `scripts/deploy.bat --frontend` 실행해야 NAS 반영.
|
||
|
||
---
|
||
|
||
## Self-Review (plan 작성 후)
|
||
|
||
### Spec coverage
|
||
- [x] §1 목적 → Task 1.x ~ 6.x 전체
|
||
- [x] §2 미학 → Task 1.2 tokens, 1.3 shell, 1.6~1.11 컴포넌트
|
||
- [x] §3 아키텍처 → Task 1.13 Me, 1.14 routes, Phase 2~5 라우트 교체
|
||
- [x] §4 컴포넌트 명세 → Task 1.4~1.12 모두 cover
|
||
- [x] §5 데이터 흐름 → Task 1.5 helpers, Task 2.3 useSajuForm 통합, Task 3.3 useSajuReading 통합
|
||
- [x] §6 반응형 + 네비 → Task 1.4 useViewportMode, 1.11 BottomNav, 1.12 DesktopHeader, 페이지마다 mode 분기
|
||
- [x] §7 Phase plan → Phase 1~6 그대로 분해
|
||
- [x] §8 에러/빈 상태 → SajuResult/Today의 EmptyState/LoadingState/ErrorState, Compatibility 폼 검증, Me placeholder
|
||
- [x] §9 검증 → Phase 6 + 각 task의 vitest 단위 테스트
|
||
- [x] §10 YAGNI 명시 제외 → i18n/dark/auth/haptic 제외, plan에 등장 X
|
||
- [x] §11 마이그레이션 → Task 6.2 일괄 삭제
|
||
- [x] §12 plan 위임 결정 → Task 3.2에서 데스크탑 4탭 유지 결정 명시, Task 1.10에서 InputRow 시그니처 명시
|
||
|
||
### Placeholder scan
|
||
- 모든 step에 실제 코드 또는 명확한 명령. "TBD/TODO" 없음.
|
||
- "Similar to Task N" 패턴 없음 — 각 Task 코드 self-contained.
|
||
- 시각 검증 step은 명확한 URL + 확인 항목 제시.
|
||
|
||
### Type consistency
|
||
- `useViewportMode()` 반환 `'mobile'` / `'desktop'` 일관
|
||
- `NAV_ITEMS` 객체 키 (`id`/`to`/`label`/`Icon`/`accent`) BottomNav + DesktopHeader 동일
|
||
- Mascot variant 키 (`full|head|upper|greeting|thinking|pointing|happy`) 모든 사용처 일치
|
||
- Trait 객체 (`{id, ko, icon, color}`) deriveTraits 반환 + TraitChip props 일치
|
||
- Hook 시그니처: useSajuForm/useSajuReading/sajuGetCurrentFortune/sajuCompatInterpret/sajuGetCompatReading는 Task 2.3/3.3/4.2/5.2/5.3에서 실제 export 확인 step 포함 (mismatch 시 inline fix 가이드)
|
||
|
||
### Fixes
|
||
- (없음 — self-review 통과)
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md` (~ Phase 6 단계, 총 30+ task, 130+ step).
|
||
|
||
두 가지 실행 옵션:
|
||
|
||
**1. Subagent-Driven (추천)** — 각 task마다 fresh subagent dispatch + 매 task 후 두 단계 review (대규모 UI 작업이라 효과 큼)
|
||
|
||
**2. Inline Execution** — 이 세션에서 batch 실행 + Phase 끝마다 checkpoint review
|
||
|
||
어떤 방식으로 진행할까요?
|