Compare commits
67 Commits
6e5aabc94c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d57f9b9b65 | |||
| 970c8164e0 | |||
| cb15ae1d24 | |||
| 6bf36f34f0 | |||
| 3656ee9a59 | |||
| e8091a0391 | |||
| a52fd0db8f | |||
| ae33aa4def | |||
| 3e73077b29 | |||
| 6e415b3e45 | |||
| 696c2ade15 | |||
| c024087c94 | |||
| d0bf5fdd50 | |||
| f6b8badd12 | |||
| 833b590afb | |||
| ce980b6eff | |||
| 4dc70a6fc6 | |||
| 57dfb3a3aa | |||
| 1dc5bc3391 | |||
| 76e6fa5e69 | |||
| ae6454ed37 | |||
| 2afcf487a1 | |||
| 0bc2ef3b98 | |||
| 726ed77b31 | |||
| 2a89d52634 | |||
| 6958714021 | |||
| 52677c606a | |||
| 96191b2d7c | |||
| 5b29854251 | |||
| 597e6504e1 | |||
| b15cbbb1b6 | |||
| dacd01e6b9 | |||
| a57ac23064 | |||
| ecc1ab0954 | |||
| d8dcf682c4 | |||
| 86f020182a | |||
| d29fdac4a0 | |||
| be762e1ee8 | |||
| 1664fbda09 | |||
| 3c64a4604f | |||
| 29f37a1642 | |||
| e1804ad181 | |||
| 6fdc2593be | |||
| 9bc31d23f5 | |||
| 0d1e8b3c2d | |||
| f8874b2aea | |||
| da694266d4 | |||
| 1bf1f1405b | |||
| e0834b1275 | |||
| 5acf7db27c | |||
| 76c7bcc62b | |||
| 9453474c69 | |||
| f924c25f16 | |||
| 7d89a664aa | |||
| 50ec52ab6e | |||
| 78e7e68bb0 | |||
| fd84e17f0b | |||
| a6d52c9725 | |||
| cc9028ac3d | |||
| 47b5eab3ff | |||
| 7f42c40efc | |||
| d34bedcb4c | |||
| 5f7e66c220 | |||
| 6040d5fd7f | |||
| dd719f5b2e | |||
| e91b7feada | |||
| ac098faeea |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ dist-ssr
|
||||
|
||||
# Superpowers visual companion (mockup files)
|
||||
.superpowers/
|
||||
|
||||
# git worktrees (subagent-driven 격리 작업)
|
||||
.worktrees/
|
||||
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
142
CLAUDE.md
142
CLAUDE.md
@@ -16,7 +16,7 @@
|
||||
| `/blog` | `Blog` | 마크다운 기반 블로그 |
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
@@ -27,8 +27,18 @@
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
|
||||
| `/saju/result?rid=N` | `SajuResult` | 사주 풀이 결과 (4탭: Basic/Chart/Flow/Traits) |
|
||||
| `/saju/today?rid=N` | `Today` | 오늘의 운세 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings) |
|
||||
| `/saju/compatibility` | `Compatibility` | 궁합 입력 (두 사람 폼) |
|
||||
| `/saju/compatibility/result?cid=N` | `CompatibilityResult` | 궁합 점수 + 요약 + strengths/challenges |
|
||||
| `/saju/me` | `SajuMe` | 마이페이지 placeholder ("곧 만나요" + 4 비활성 카드) |
|
||||
| `/tarot` | `Tarot` | 타로 메인 (agent-office에서 분리, tarot-lab API) |
|
||||
| `/tarot/today` | `TarotTodayCard` | 오늘의 카드 (one_card spread) |
|
||||
| `/tarot/reading` | `TarotReading` | 멀티 카드 스프레드 리딩 (three_card 등) |
|
||||
| `/tarot/history` | `TarotHistory` | 리딩 이력 조회 |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -92,6 +102,10 @@ proxy: {
|
||||
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||
@@ -128,6 +142,23 @@ proxy: {
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||
| 사주 | POST | `/api/saju/interpret` — body: `{ year, month, day, hour, gender, calendar_type, is_leap_month? }` → reading_id + saju_data + analysis + fortune_scores + lucky + monthly_flow |
|
||||
| 사주 | GET | `/api/saju/readings/:id` — 저장된 사주 조회 (`useSajuReading` hook 사용) |
|
||||
| 사주 | GET | `/api/saju/current-fortune?reading_id=N` — 현재 연도 세운 |
|
||||
| 사주 | PATCH/DELETE | `/api/saju/readings/:id` — 즐겨찾기·메모 / 삭제 |
|
||||
| 사주 | GET | `/api/saju/readings?page=1&size=20&favorite=bool` — 목록 |
|
||||
| 궁합 | POST | `/api/saju/compat/interpret` — body: `{ person_a, person_b }` → compat_id + score + interpretation |
|
||||
| 궁합 | GET | `/api/saju/compat/readings/:id` — 궁합 결과 |
|
||||
| 궁합 | PATCH/DELETE | `/api/saju/compat/readings/:id` |
|
||||
| 타로 | POST | `/api/tarot/interpret` — body: `{ spread_type, category, question, cards }` → interpretation_json (DB 저장 X) |
|
||||
| 타로 | POST | `/api/tarot/readings` — 확정 후 저장 |
|
||||
| 타로 | GET | `/api/tarot/readings?page=1&spread_type=X&category=Y` — 목록 |
|
||||
| 타로 | GET/PATCH/DELETE | `/api/tarot/readings/:id` |
|
||||
| 영상 생성 | POST | `/api/video/generate` — body: `{ provider, prompt, params }` → task_id (sora/veo/kling/seedance) |
|
||||
| 영상 생성 | GET | `/api/video/tasks/:id`, `/api/video/providers` |
|
||||
| 이미지 생성 | POST | `/api/image/generate` — body: `{ provider, prompt, params }` → task_id (gpt_image/nano_banana/flux) |
|
||||
| 이미지 생성 | GET | `/api/image/tasks/:id`, `/api/image/providers` |
|
||||
| 에이전트 로그 | GET | `/api/agent-office/agents/:id/logs?limit=50` — DB agent_logs + 컨테이너 `/logs/recent` 병합 |
|
||||
|
||||
---
|
||||
|
||||
@@ -332,6 +363,102 @@ handleGenerate()
|
||||
|
||||
---
|
||||
|
||||
## 호령 사주 v2 — `/saju` 라우트 트리
|
||||
|
||||
2026-05-27 풀 리디자인 (Phase 1-6, 30 commits). v1 `components/` + `Saju.css` 일괄 삭제 후 신규 디자인 시스템 도입.
|
||||
|
||||
### 디자인 컨셉
|
||||
|
||||
한국 전통 명리학 미학 + 호령 캐릭터. Inter/Roboto 같은 generic AI sans 회피.
|
||||
- **타이포**: Nanum Myeongjo (display, weight 800) + Nanum Gothic (body) + Gowun Batang (fallback serif). `index.html` head에서 preconnect + link 일괄 로드 (기존 Noto Serif KR도 v1 호환 유지)
|
||||
- **컬러**: navy `#1F2A44` dominant + gold `#D4AF37` accent + ivory `#F7F2E8` paper. 화면별 단일 accent (홈=navy, 오늘=gold, 궁합=green, 사주풀이=purple, 마이=gray)
|
||||
- **차별화 요소**: `OrnateFrame` (4 코너 꺽쇠 + double border), `MascotBubble` (paw-bob 2.4s 애니메이션), `OrnamentBloom` (꽃봉오리 SVG), `mt-wash` (산수화 SVG 데스크탑 배경)
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
src/pages/saju/
|
||||
├── _shell/ # 디자인 시스템 + 네비
|
||||
│ ├── tokens.css # CSS 변수 (.saju-v2 scope)
|
||||
│ ├── shell.css # paper-bg/night-bg/mt-wash/screenIn/paw-bob
|
||||
│ ├── useViewportMode.js # 1024px breakpoint hook
|
||||
│ ├── BottomNav.jsx # 모바일 5항목
|
||||
│ ├── DesktopHeader.jsx # 데스크탑 헤더 nav
|
||||
│ ├── Mascot.jsx # 7 variant 매핑 (full/head/upper/greeting/thinking/pointing/happy)
|
||||
│ ├── MascotBubble.jsx # 4 tone (ivory/navy/green/purple)
|
||||
│ ├── OrnateFrame.jsx
|
||||
│ ├── OrnamentBloom.jsx
|
||||
│ ├── TopRibbon.jsx
|
||||
│ ├── TitleBlock.jsx
|
||||
│ ├── PrimaryButton.jsx
|
||||
│ ├── GhostButton.jsx
|
||||
│ ├── InputRow.jsx
|
||||
│ ├── Icons.jsx # 5 nav + IconPaw/Chevron/Sparkle
|
||||
│ └── helpers/
|
||||
│ ├── hexA.js # hex + alpha → rgba
|
||||
│ ├── daeunLabel.js # 나이 → 8 인생 단계 label
|
||||
│ ├── deriveTraits.js # element_scores → 6 성향
|
||||
│ └── colorMap.js # 오행 한자 → CSS var + 한글/한자
|
||||
├── views/ # mobile/desktop 컴포넌트 분리
|
||||
│ ├── home.{mobile,desktop}.jsx
|
||||
│ ├── saju.{mobile,desktop}.jsx # 4탭 (Basic/Chart/Flow/Traits)
|
||||
│ ├── today.{mobile,desktop}.jsx
|
||||
│ └── match.{mobile,desktop}.jsx
|
||||
├── hooks/
|
||||
│ ├── useSajuForm.js # 폼 상태 (year/month/day/hour/gender/calendar_type, handleChange(field,value) 콜백)
|
||||
│ └── useSajuReading.js # rid 기반 { data, loading, error }
|
||||
├── Saju.jsx # /saju 진입 router
|
||||
├── SajuResult.jsx # /saju/result 진입 (Empty/Loading/Error state)
|
||||
├── Today.jsx
|
||||
├── Compatibility.jsx
|
||||
├── CompatibilityResult.jsx
|
||||
└── Me.jsx # placeholder
|
||||
```
|
||||
|
||||
### 응답 schema 매핑 (saju-lab → view)
|
||||
|
||||
`useSajuReading(rid).data` 구조:
|
||||
- `saju_data.{year,month,day,hour}` 각 `{stem, stem_kr, branch, branch_kr, ten_god, fortune}` (4기둥)
|
||||
- `analysis_data.element_scores` (한자 키 `木/火/土/金/水`) — view에서 `wood/fire/earth/metal/water`로 매핑 (`HANJA_TO_ID`)
|
||||
- `analysis_data.day_master_strength.{result, score, reasons}` (신강신약)
|
||||
- `daeun_data` (8개): `{age, start_year, end_year, stem, branch, stem_kr, branch_kr}` — 현재 판정 `start_year ≤ currentYear ≤ end_year`
|
||||
- `interpretation_json.{summary, items: [{key,title,content,evidence}], advice}`
|
||||
- `fortune_scores.{wealth, romance, social, career, overall}` (0-100)
|
||||
- `lucky.{color: string[], number, direction, good_signs: string[], warnings: string[]}`
|
||||
|
||||
### 반응형 전략
|
||||
|
||||
1024px breakpoint로 모바일/데스크탑 컴포넌트 트리 완전 분리:
|
||||
- 모바일 (< 1024): `night-bg` 또는 `paper-bg`, BottomNav 하단 fixed + safe-area
|
||||
- 데스크탑 (≥ 1024): `mt-wash` 산수화 배경, DesktopHeader sticky top, content max-width 1200px
|
||||
|
||||
### 호령 자산
|
||||
|
||||
`public/images/saju/horyung/` 7 PNG (horyung-main/bust/front/greeting/thinking/pointing/happy). Mascot variant API가 매핑:
|
||||
- `full` → horyung-main, `head` → horyung-bust, `upper` → horyung-front, 나머지는 1:1
|
||||
|
||||
---
|
||||
|
||||
## 타로 — `/tarot` 라우트 트리
|
||||
|
||||
agent-office에서 독립 라우트로 분리 (백엔드는 `tarot-lab` 컨테이너).
|
||||
|
||||
| 경로 | 컴포넌트 | 백엔드 |
|
||||
|------|----------|--------|
|
||||
| `/tarot` | `Tarot` | tarot-lab `/api/tarot/interpret` |
|
||||
| `/tarot/today` | `TarotTodayCard` | one_card spread |
|
||||
| `/tarot/reading` | `TarotReading` | three_card spread + 멀티 |
|
||||
| `/tarot/history` | `TarotHistory` | `/api/tarot/readings` 목록 |
|
||||
|
||||
해석 흐름 (interpret ↔ save 분리):
|
||||
1. 사용자가 카드 배치 → `POST /api/tarot/interpret` → Claude 응답 (DB 저장 X)
|
||||
2. 사용자 확정 또는 reroll 결정
|
||||
3. 확정 후 `POST /api/tarot/readings` → DB 저장 + reading_id 반환
|
||||
|
||||
`useTarotReading(id)` + `useTarotShuffle()` hook (`src/pages/tarot/hooks/`).
|
||||
|
||||
---
|
||||
|
||||
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||
|
||||
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||
@@ -372,3 +499,14 @@ web-ui → POST /api/music/generate (NAS music-lab)
|
||||
```
|
||||
|
||||
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||
|
||||
---
|
||||
|
||||
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **FE**
|
||||
|
||||
이 세션은 프론트엔드(FE) 역할이다. co-gahusb MCP 툴로 다른 세션(BE/AI/Producer)과 협업한다.
|
||||
- **소유권**: 이 세션은 `web-ui` repo만 쓴다(BE=web-backend, AI=web-ai).
|
||||
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "FE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||
- **모든 툴 호출에 `role="FE"`** (또는 `from_role`/`created_by`에 FE).
|
||||
- **수신**: `/loop`로 주기적으로 `read_inbox("FE", after_id=<last>)` + `list_tasks(assignee_role="FE")` 확인.
|
||||
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||
|
||||
@@ -64,14 +64,15 @@
|
||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||
|
||||
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||
### Stock Trade (`/stock/trade`) — 8 컴포넌트
|
||||
|
||||
포트폴리오 관리 및 트레이딩 데스크.
|
||||
포트폴리오 관리 및 트레이딩 데스크 (5탭).
|
||||
|
||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이 + AI 코치
|
||||
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||
- **보유종목 인텔 탭**: 스크리너 엔진 기반 기술분석·매도룰 신호 (어드바이저리)
|
||||
- **관심종목 탭**: 관심종목 CRUD + 실시간 매매 시그널 알림 이력 (매수/매도 시그널, 1D/7D/30D 필터) — 실시간 매매 알림 파이프라인(BE 엔드포인트 + web-ai `trade-monitor` 워커) 연동
|
||||
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||
- 매도 히스토리 드로어 (실현손익 추적)
|
||||
|
||||
|
||||
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
@@ -0,0 +1,765 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 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:** AgentOffice 우측 패널(에이전트 미선택 시)에 전 에이전트 활동을 시간순으로 보여주는 횡단 오버사이트 타임라인을 추가한다.
|
||||
|
||||
**Architecture:** 백엔드 `GET /api/agent-office/activity`(필터 지원)를 소비. `useActivityFeed` 훅이 페이지네이션·필터·WS refreshTrigger 재조회를 담당하고, `ActivityTimeline`이 `ActivityFilters` + `ActivityItem` 리스트 + IntersectionObserver 무한스크롤을 조립한다. AgentOffice는 `selectedAgent===null`일 때 기존 `EmptyDetailPanel`을 `ActivityTimeline`으로 교체한다.
|
||||
|
||||
**Tech Stack:** React 18, vitest + @testing-library/react(v16, `renderHook` 사용), 기존 `ao-*` CSS 컨벤션, `AGENT_META` 색상/표시명 재사용.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `src/api.js` (수정) | `agentActivity({agent_id,type,status,days,limit,offset})` 헬퍼 추가 |
|
||||
| `src/pages/agent-office/hooks/useActivityFeed.js` (생성) | items/total/loading/error/hasMore 상태, 필터·refreshTrigger 재조회, loadMore append |
|
||||
| `src/pages/agent-office/components/ActivityItem.jsx` (생성) | 한 행: agent 색·표시명 + 메시지 + 상태/level 뱃지 + 시간/duration, 클릭 → onSelectAgent |
|
||||
| `src/pages/agent-office/components/ActivityFilters.jsx` (생성) | agent/type/status/days select 4종, type=log 시 status 비활성 |
|
||||
| `src/pages/agent-office/components/ActivityTimeline.jsx` (생성) | 컨테이너: 헤더 + 필터 + 리스트 + sentinel + 상태 |
|
||||
| `src/pages/agent-office/AgentOffice.jsx` (수정) | null 분기를 ActivityTimeline으로 교체 |
|
||||
| `src/pages/agent-office/AgentOffice.css` (수정) | 타임라인 baseline 스타일 (Task 7) → designer 마감 (Task 8) |
|
||||
| 각 `*.test.{js,jsx}` | hook/Item/Filters 단위 테스트 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `agentActivity` API 헬퍼
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api.js` (기존 `getActivityFeed` 줄 근처, 596라인 부근)
|
||||
|
||||
- [ ] **Step 1: 헬퍼 추가**
|
||||
|
||||
`src/api.js`에서 기존 줄
|
||||
```js
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
```
|
||||
바로 아래에 추가:
|
||||
```js
|
||||
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (agent_id) p.set('agent_id', agent_id);
|
||||
if (type) p.set('type', type);
|
||||
if (status) p.set('status', status);
|
||||
if (days) p.set('days', String(days));
|
||||
p.set('limit', String(limit));
|
||||
p.set('offset', String(offset));
|
||||
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: lint 통과 확인**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 에러 없음 (no-unused-vars 등)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.js
|
||||
git commit -m "feat(agent-office): agentActivity API 헬퍼 추가"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `useActivityFeed` 훅 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/hooks/useActivityFeed.js`
|
||||
- Test: `src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/hooks/useActivityFeed.test.js`:
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useActivityFeed } from './useActivityFeed.js';
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => mockAgentActivity.mockReset());
|
||||
|
||||
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||
|
||||
describe('useActivityFeed', () => {
|
||||
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
|
||||
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { result.current.loadMore(); });
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||
});
|
||||
|
||||
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||
rerender({ rt: 1 });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('hasMore는 items.length < total', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
Expected: FAIL — "Failed to resolve import './useActivityFeed.js'" 또는 useActivityFeed undefined
|
||||
|
||||
- [ ] **Step 3: 훅 구현**
|
||||
|
||||
`src/pages/agent-office/hooks/useActivityFeed.js`:
|
||||
```js
|
||||
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { agentActivity } from '../../../api';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const offsetRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
const fetchPage = useCallback(async (offset, replace) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||
setTotal(data?.total || 0);
|
||||
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||
offsetRef.current = offset + newItems.length;
|
||||
} catch (e) {
|
||||
setError(e.message || '불러오기 실패');
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [filterKey, refreshTrigger, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingRef.current) return;
|
||||
if (offsetRef.current >= total) return;
|
||||
fetchPage(offsetRef.current, false);
|
||||
}, [fetchPage, total]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [fetchPage]);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js`
|
||||
Expected: PASS (5 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/hooks/useActivityFeed.js src/pages/agent-office/hooks/useActivityFeed.test.js
|
||||
git commit -m "feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `ActivityItem` 컴포넌트 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityItem.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityItem.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
describe('ActivityItem', () => {
|
||||
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityItem.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityItem.jsx
|
||||
import { AGENT_META } from '../constants.js';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||
};
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
error: { icon: '❌', cls: 'level-error' },
|
||||
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||
info: { icon: '·', cls: 'level-info' },
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ item, onSelectAgent }) {
|
||||
const meta = AGENT_META[item.agent_id];
|
||||
const color = meta?.color || '#6b7280';
|
||||
const name = meta?.displayName || item.agent_id;
|
||||
const isTask = item.type === 'task';
|
||||
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityItem.jsx src/pages/agent-office/components/ActivityItem.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ActivityFilters` 컴포넌트 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityFilters.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityFilters.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
|
||||
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
describe('ActivityFilters', () => {
|
||||
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityFilters.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'succeeded', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'pending', label: '대기' },
|
||||
];
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1일' },
|
||||
{ value: 7, label: '7일' },
|
||||
{ value: 30, label: '30일' },
|
||||
];
|
||||
|
||||
export default function ActivityFilters({ filters, onChange }) {
|
||||
const set = (patch) => onChange({ ...filters, ...patch });
|
||||
const statusDisabled = filters.type === 'log';
|
||||
return (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityFilters.jsx src/pages/agent-office/components/ActivityFilters.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityFilters (agent/type/status/days)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ActivityTimeline` 컨테이너 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/agent-office/components/ActivityTimeline.jsx`
|
||||
- Test: `src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
|
||||
> 참고: jsdom에는 IntersectionObserver가 없으므로 테스트 setup에서 stub이 필요하다. Step 1에서 테스트 파일 상단에 직접 stub을 둔다(전역 test-setup 수정 없이 국소 처리).
|
||||
|
||||
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||
|
||||
`src/pages/agent-office/components/ActivityTimeline.test.jsx`:
|
||||
```jsx
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||
|
||||
// jsdom IntersectionObserver stub
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
describe('ActivityTimeline', () => {
|
||||
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
Expected: FAIL — import 해결 실패
|
||||
|
||||
- [ ] **Step 3: 컴포넌트 구현**
|
||||
|
||||
`src/pages/agent-office/components/ActivityTimeline.jsx`:
|
||||
```jsx
|
||||
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '120px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/components/ActivityTimeline.jsx src/pages/agent-office/components/ActivityTimeline.test.jsx
|
||||
git commit -m "feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: AgentOffice 우측 패널 배선
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.jsx`
|
||||
|
||||
- [ ] **Step 1: import 추가**
|
||||
|
||||
`src/pages/agent-office/AgentOffice.jsx`에서
|
||||
```js
|
||||
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||
```
|
||||
바로 아래에 추가:
|
||||
```js
|
||||
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: null 분기 교체**
|
||||
|
||||
같은 파일에서
|
||||
```js
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
```
|
||||
를 아래로 변경:
|
||||
```js
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 전체 테스트 통과 확인 (회귀 없음)**
|
||||
|
||||
Run: `npm run test:run`
|
||||
Expected: PASS — 신규 테스트 포함 전부 통과, 기존 테스트 회귀 없음
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/AgentOffice.jsx
|
||||
git commit -m "feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: baseline CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.css` (파일 끝에 append)
|
||||
|
||||
- [ ] **Step 1: 스타일 추가**
|
||||
|
||||
`src/pages/agent-office/AgentOffice.css` 맨 끝에 추가:
|
||||
```css
|
||||
/* ── 횡단 오버사이트 타임라인 ── */
|
||||
.ao-activity { display: flex; flex-direction: column; min-height: 0; }
|
||||
.ao-activity-header { display: flex; align-items: center; }
|
||||
|
||||
.ao-activity-filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
.ao-activity-select {
|
||||
background: #111827; color: #e5e7eb;
|
||||
border: 1px solid #374151; border-radius: 6px;
|
||||
padding: 4px 8px; font-size: 12px;
|
||||
}
|
||||
.ao-activity-select:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
|
||||
.ao-activity-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #161b25;
|
||||
cursor: pointer; transition: background .12s;
|
||||
}
|
||||
.ao-activity-item:hover { background: #161b25; }
|
||||
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, .08); }
|
||||
.ao-activity-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
|
||||
.ao-activity-body { flex: 1; min-width: 0; }
|
||||
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||
.ao-activity-agent { font-size: 12px; font-weight: 600; }
|
||||
.ao-activity-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
|
||||
.ao-activity-level { font-size: 12px; }
|
||||
.ao-activity-msg {
|
||||
font-size: 13px; color: #cbd5e1; margin-top: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.ao-activity-time { font-size: 11px; color: #6b7280; }
|
||||
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||
|
||||
.ao-activity-loading,
|
||||
.ao-activity-end { text-align: center; padding: 12px; font-size: 12px; color: #6b7280; }
|
||||
.ao-activity-sentinel { height: 1px; }
|
||||
.ao-activity-error { padding: 12px; font-size: 13px; color: #fca5a5; }
|
||||
.ao-activity-error button {
|
||||
margin-left: 8px; background: #374151; color: #e5e7eb;
|
||||
border: none; border-radius: 6px; padding: 2px 10px; cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 개발 서버에서 시각 확인**
|
||||
|
||||
Run: `npm run dev` 후 브라우저에서 `http://localhost:3007/agent-office` 접속 → 우측 패널에 타임라인/필터/항목이 보이는지 확인 (에이전트 미선택 상태).
|
||||
Expected: 필터 4종 + 활동 항목 리스트 표시, 항목 클릭 시 SidePanel 전환
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/agent-office/AgentOffice.css
|
||||
git commit -m "style(agent-office): 횡단 타임라인 baseline 스타일"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: designer 스킬 비주얼 마감 + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/agent-office/AgentOffice.css` (+ 필요 시 컴포넌트 className 미세 조정)
|
||||
|
||||
- [ ] **Step 1: designer 스킬 적용**
|
||||
|
||||
`designer` 스킬을 invoke하여 AgentOffice 다크 미감과 일관된 타임라인 비주얼로 마감 (에이전트 색 강조, 상태 뱃지 가독성, 펄스 애니메이션, 밀도/여백). 기능/마크업 구조는 유지하고 스타일만 개선.
|
||||
|
||||
- [ ] **Step 2: lint + 전체 테스트 + 빌드 검증**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run test:run
|
||||
npm run build
|
||||
```
|
||||
Expected: lint 0 error, 전체 테스트 PASS, build 성공
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "style(agent-office): designer 마감 — 횡단 오버사이트 타임라인"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 체크리스트 (작성자 검증 완료)
|
||||
|
||||
- **Spec coverage:** agentActivity 헬퍼(T1) ✓ / useActivityFeed 필터·페이지네이션·refreshTrigger(T2) ✓ / 상태·level 뱃지 + agent 색 + 클릭(T3) ✓ / 필터 4종 + log시 status 비활성(T4) ✓ / 무한스크롤·empty·error·end(T5) ✓ / AgentOffice 배선(T6) ✓ / 비주얼(T7·T8) ✓ — spec 전 항목 커버.
|
||||
- **Placeholder scan:** 모든 step에 실제 코드/명령/기대출력 포함, TBD 없음.
|
||||
- **Type consistency:** `useActivityFeed(filters, refreshTrigger)` 반환 `{items,total,loading,error,hasMore,loadMore,retry}` — T5에서 동일 사용. `onSelectAgent(agent_id)` 시그니처 T3/T5/T6 일치. `AGENT_META`/`ACTIVE_AGENT_IDS` import 경로 `../constants.js` 일치. `agentActivity({...})` 객체 인자 T1 정의 ↔ T2 호출 일치.
|
||||
- **Known caveat:** jsdom IntersectionObserver 없음 → T5 테스트 상단 stub으로 처리(전역 setup 미수정).
|
||||
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
@@ -0,0 +1,906 @@
|
||||
# 관심종목 탭 (Watchlist Tab) 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:** `/stock/trade` 거래 데스크에 관심종목 CRUD + 최근 매매 시그널 알림 이력을 보여주는 "관심종목" 탭을 추가한다.
|
||||
|
||||
**Architecture:** 순수 헬퍼(`watchlistUtils.js`) → API 헬퍼(`api.js`) → 상태 훅(`useWatchlist.js`) → 표현 컴포넌트(`WatchlistTab.jsx`) → 탭 등재(`StockTrade.jsx`). 기존 `HoldingsIntelTab`/`usePortfolio` 패턴(훅을 `StockTrade`에서 인스턴스화해 탭에 props로 전달)을 그대로 따른다.
|
||||
|
||||
**Tech Stack:** React 18 (함수형 + hooks), Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiDelete` 헬퍼.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API는 항상 상대경로** (`/api/...`). 절대 URL 금지 (Mixed Content).
|
||||
- **모든 fetch는 `src/api.js`의 `apiGet/apiPost/apiDelete` 경유.**
|
||||
- 테스트: `import { describe, it, expect } from 'vitest'`. 실행 `npm run test:run`. 파일 컨벤션 `*.test.js(x)` 동일 디렉토리 배치.
|
||||
- 색상: 매수 `#22c55e`, 매도 `#ef4444` (기존 `ACTION_MAP` 팔레트 일치).
|
||||
- CSS 토큰 재사용: `--line`, `--surface`, `--radius-lg`, `--muted`, `--accent-stock`. 카드 관례: `background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px`.
|
||||
- 커밋은 `web-ui` 경로에서만. `.env`·무관 파일 커밋 금지 (변경 파일만 명시적 `git add`).
|
||||
- BE 계약 (소비 대상):
|
||||
- `GET /api/stock/watchlist` → `{ watchlist: [{ ticker, name, note, params, added_at }] }`
|
||||
- `POST /api/stock/watchlist` body `{ ticker, name?, note? }` → `{ ok: true }`
|
||||
- `DELETE /api/stock/watchlist/{ticker}` → 200/404
|
||||
- `GET /api/stock/trade-alerts?days=N` → `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }`
|
||||
- `kind`: `buy`|`sell`. `condition`: `buy_ma20_pullback`/`buy_breakout`/`buy_rsi_bounce`/`sell_stop_loss`/`sell_ma_break`/`sell_take_profit`/`sell_climax`/`sell_trailing_stop`.
|
||||
- 응답은 방어적 파싱: 배열 직접 반환 / 래핑(`watchlist`·`alerts`) 둘 다 허용.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 순수 헬퍼 `watchlistUtils.js` (라벨/색/시간 매핑)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/watchlistUtils.js`
|
||||
- Test: `src/pages/stock/watchlistUtils.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `KIND_META: { buy: {label,color,bg}, sell: {label,color,bg} }`
|
||||
- `kindMeta(kind: string) => { label, color, bg }` (미정의 → 회색 폴백 + 원문 label)
|
||||
- `CONDITION_LABEL: Record<string,string>`
|
||||
- `conditionLabel(cond: string) => string` (미정의 → 원문 폴백)
|
||||
- `normalizeTicker(str) => string` (trim만)
|
||||
- `relativeTime(iso: string, now?: number) => string`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js';
|
||||
|
||||
describe('kindMeta', () => {
|
||||
it('buy/sell 라벨과 색을 반환', () => {
|
||||
expect(kindMeta('buy').label).toBe('매수');
|
||||
expect(kindMeta('buy').color).toBe('#22c55e');
|
||||
expect(kindMeta('sell').label).toBe('매도');
|
||||
expect(kindMeta('sell').color).toBe('#ef4444');
|
||||
});
|
||||
it('미정의 kind는 회색 폴백 + 원문 label', () => {
|
||||
const m = kindMeta('weird');
|
||||
expect(m.label).toBe('weird');
|
||||
expect(m.color).toBe('#94a3b8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionLabel', () => {
|
||||
it('정의된 8종을 한글로 매핑', () => {
|
||||
expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등');
|
||||
expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파');
|
||||
expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등');
|
||||
expect(conditionLabel('sell_stop_loss')).toBe('손절 라인');
|
||||
expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈');
|
||||
expect(conditionLabel('sell_take_profit')).toBe('목표가 도달');
|
||||
expect(conditionLabel('sell_climax')).toBe('과열 소진');
|
||||
expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱');
|
||||
});
|
||||
it('미정의 condition은 원문 폴백', () => {
|
||||
expect(conditionLabel('buy_unknown')).toBe('buy_unknown');
|
||||
expect(conditionLabel(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTicker', () => {
|
||||
it('공백 trim', () => {
|
||||
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
||||
expect(normalizeTicker(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
||||
it('60초 미만은 방금', () => {
|
||||
expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금');
|
||||
});
|
||||
it('분/시간/어제/일 경계', () => {
|
||||
expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전');
|
||||
expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전');
|
||||
expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제');
|
||||
expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전');
|
||||
});
|
||||
it('잘못된/빈 값은 빈 문자열', () => {
|
||||
expect(relativeTime('', now)).toBe('');
|
||||
expect(relativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./watchlistUtils.js"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.js`:
|
||||
|
||||
```js
|
||||
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
||||
|
||||
export const KIND_META = {
|
||||
buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
};
|
||||
|
||||
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
||||
|
||||
export const kindMeta = (kind) => {
|
||||
const meta = KIND_META[kind];
|
||||
if (meta) return meta;
|
||||
return { ...FALLBACK_KIND, label: kind ?? '' };
|
||||
};
|
||||
|
||||
export const CONDITION_LABEL = {
|
||||
buy_ma20_pullback: 'MA20 눌림 반등',
|
||||
buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등',
|
||||
sell_stop_loss: '손절 라인',
|
||||
sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달',
|
||||
sell_climax: '과열 소진',
|
||||
sell_trailing_stop: '트레일링 스톱',
|
||||
};
|
||||
|
||||
export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? '';
|
||||
|
||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||
|
||||
export const relativeTime = (iso, now = Date.now()) => {
|
||||
if (!iso) return '';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '방금';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return '방금';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return '어제';
|
||||
if (day < 7) return `${day}일 전`;
|
||||
return new Date(iso).toLocaleDateString('ko-KR');
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: PASS (4 describe 블록 전부 통과).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/watchlistUtils.js src/pages/stock/watchlistUtils.test.js
|
||||
git commit -m "feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API 헬퍼 + `useWatchlist` 훅
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api.js` (파일 끝에 추가)
|
||||
- Create: `src/pages/stock/hooks/useWatchlist.js`
|
||||
- Test: `src/pages/stock/hooks/useWatchlist.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `normalizeTicker`
|
||||
- Produces:
|
||||
- `api.js`: `getWatchlist()`, `addWatchlist(body)`, `removeWatchlist(ticker)`, `getTradeAlerts(days=7)`
|
||||
- `useWatchlist() => { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload }`
|
||||
- `add({ ticker, name?, note? })` — 낙관적 추가 후 `reload`, 실패 시 롤백
|
||||
- `remove(ticker)` — 낙관적 제거, 실패 시 롤백
|
||||
|
||||
- [ ] **Step 1: Add API helpers**
|
||||
|
||||
`src/api.js` 파일 맨 끝(마지막 `compatDeleteReading` 함수 뒤)에 추가:
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing hook test**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getWatchlist: vi.fn(),
|
||||
addWatchlist: vi.fn(),
|
||||
removeWatchlist: vi.fn(),
|
||||
getTradeAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import useWatchlist from './useWatchlist';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] });
|
||||
getTradeAlerts.mockResolvedValue({ alerts: [] });
|
||||
addWatchlist.mockResolvedValue({ ok: true });
|
||||
removeWatchlist.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe('useWatchlist', () => {
|
||||
it('마운트 시 watchlist를 로드', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('005930');
|
||||
});
|
||||
|
||||
it('배열 직접 반환도 방어적으로 파싱', async () => {
|
||||
getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]);
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('000660');
|
||||
});
|
||||
|
||||
it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => {
|
||||
getWatchlist
|
||||
.mockResolvedValueOnce({ watchlist: [] })
|
||||
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
||||
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
||||
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
||||
});
|
||||
|
||||
it('add 실패 시 롤백 + error', async () => {
|
||||
getWatchlist.mockResolvedValue({ watchlist: [] });
|
||||
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: '000660' }); });
|
||||
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
||||
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
||||
});
|
||||
|
||||
it('중복 ticker는 add 차단', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.add({ ticker: '005930' }); });
|
||||
expect(addWatchlist).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toContain('이미');
|
||||
});
|
||||
|
||||
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.remove('005930'); });
|
||||
expect(removeWatchlist).toHaveBeenCalledWith('005930');
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => {
|
||||
getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./useWatchlist"` (파일 없음).
|
||||
|
||||
- [ ] **Step 4: Write the hook**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.js`:
|
||||
|
||||
```js
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import { normalizeTicker } from '../watchlistUtils';
|
||||
|
||||
const asArray = (data, key) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data[key])) return data[key];
|
||||
return [];
|
||||
};
|
||||
|
||||
const byFiredAtDesc = (a, b) =>
|
||||
new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime();
|
||||
|
||||
export default function useWatchlist() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [alertDays, setAlertDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [alertError, setAlertError] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getWatchlist();
|
||||
setItems(asArray(data, 'watchlist'));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlerts = useCallback(async (days) => {
|
||||
setAlertError('');
|
||||
try {
|
||||
const data = await getTradeAlerts(days);
|
||||
setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc));
|
||||
} catch (e) {
|
||||
setAlertError(e?.message ?? String(e));
|
||||
setAlerts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWatchlist(); }, [loadWatchlist]);
|
||||
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
||||
|
||||
const add = useCallback(async ({ ticker, name, note }) => {
|
||||
const t = normalizeTicker(ticker);
|
||||
if (!t) return;
|
||||
if (items.some((it) => it.ticker === t)) {
|
||||
setError(`이미 관심종목에 있습니다: ${t}`);
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError('');
|
||||
const cleanName = (name ?? '').trim();
|
||||
const cleanNote = (note ?? '').trim();
|
||||
const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() };
|
||||
setItems((prev) => [optimistic, ...prev]);
|
||||
try {
|
||||
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
||||
await loadWatchlist();
|
||||
} catch (e) {
|
||||
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [items, loadWatchlist]);
|
||||
|
||||
const remove = useCallback(async (ticker) => {
|
||||
const prev = items;
|
||||
setItems((cur) => cur.filter((it) => it.ticker !== ticker));
|
||||
setError('');
|
||||
try {
|
||||
await removeWatchlist(ticker);
|
||||
} catch (e) {
|
||||
setItems(prev); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
items, alerts, alertDays, setAlertDays,
|
||||
loading, error, alertError, adding,
|
||||
add, remove, reload: loadWatchlist,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: PASS (7 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.js src/pages/stock/hooks/useWatchlist.js src/pages/stock/hooks/useWatchlist.test.js
|
||||
git commit -m "feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `WatchlistTab.jsx` 컴포넌트 + 스타일
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/WatchlistTab.jsx`
|
||||
- Modify: `src/pages/stock/Stock.css` (파일 끝에 `wl-*` 섹션 추가)
|
||||
- Test: `src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `kindMeta`, `conditionLabel`, `relativeTime`; (stockUtils) `formatNumber`; (Task 2) `useWatchlist` 반환 형태 — 단, 컴포넌트는 훅 결과를 `wl` **prop**으로 받는다(테스트/뱃지 용이).
|
||||
- Produces: `WatchlistTab({ wl })` 기본 export (React 컴포넌트).
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.test.jsx`:
|
||||
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import WatchlistTab from './WatchlistTab.jsx';
|
||||
|
||||
const baseWl = {
|
||||
items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(),
|
||||
loading: false, error: '', alertError: '', adding: false,
|
||||
add: vi.fn(), remove: vi.fn(), reload: vi.fn(),
|
||||
};
|
||||
|
||||
describe('WatchlistTab', () => {
|
||||
it('빈 상태: 헤딩과 빈 안내 노출', () => {
|
||||
render(<WatchlistTab wl={baseWl} />);
|
||||
expect(screen.getByText('관심종목 관리')).toBeInTheDocument();
|
||||
expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('종목·알림이 있으면 렌더', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }],
|
||||
alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('삼성전자')).toBeInTheDocument();
|
||||
expect(screen.getByText('매수')).toBeInTheDocument();
|
||||
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> 참고: `toBeInTheDocument` 매처는 `@testing-library/jest-dom`(devDependency)에서 제공된다. 기존 테스트 셋업에서 전역 등록이 안 되어 있으면 테스트 파일 상단에 `import '@testing-library/jest-dom';` 한 줄을 추가한다.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: FAIL — `Failed to resolve import "./WatchlistTab.jsx"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the component**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.jsx`:
|
||||
|
||||
```jsx
|
||||
import React, { useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils';
|
||||
import { formatNumber } from '../stockUtils';
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1D' },
|
||||
{ value: 7, label: '7D' },
|
||||
{ value: 30, label: '30D' },
|
||||
];
|
||||
|
||||
const AlertCard = ({ a }) => {
|
||||
const meta = kindMeta(a.kind);
|
||||
return (
|
||||
<div className="wl-alert">
|
||||
<div className="wl-alert__head">
|
||||
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
|
||||
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
|
||||
<span className="wl-alert__ticker">{a.ticker}</span>
|
||||
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
|
||||
</div>
|
||||
<div className="wl-alert__body">
|
||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||
</div>
|
||||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchlistTab = ({ wl }) => {
|
||||
const [form, setForm] = useState({ ticker: '', name: '', note: '' });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.ticker.trim()) return;
|
||||
await wl.add(form);
|
||||
setForm({ ticker: '', name: '', note: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 관심종목 관리 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">관심종목</p>
|
||||
<h3>관심종목 관리</h3>
|
||||
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
|
||||
</div>
|
||||
|
||||
<form className="wl-form" onSubmit={submit}>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목코드 (예: 005930)"
|
||||
value={form.ticker}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목명 (선택)"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="메모 (선택)"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
|
||||
{wl.adding ? '추가 중…' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{wl.error && <p className="stock-error">{wl.error}</p>}
|
||||
|
||||
{wl.items.length === 0 ? (
|
||||
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
|
||||
) : (
|
||||
<ul className="wl-list">
|
||||
{wl.items.map((it) => (
|
||||
<li key={it.ticker} className="wl-row">
|
||||
<div className="wl-row__meta">
|
||||
<strong className="wl-row__name">{it.name || it.ticker}</strong>
|
||||
<span className="wl-row__ticker">{it.ticker}</span>
|
||||
{it.note && <span className="wl-row__note">{it.note}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="wl-del"
|
||||
type="button"
|
||||
aria-label={`${it.ticker} 삭제`}
|
||||
onClick={() => wl.remove(it.ticker)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 최근 시그널 알림 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">시그널</p>
|
||||
<h3>최근 매매 알림</h3>
|
||||
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
|
||||
</div>
|
||||
<div className="wl-period-toggle">
|
||||
{DAYS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
|
||||
onClick={() => wl.setAlertDays(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
|
||||
|
||||
{wl.alerts.length === 0 ? (
|
||||
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
|
||||
) : (
|
||||
<div className="wl-alerts">
|
||||
{wl.alerts.map((a) => (
|
||||
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistTab;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Append styles to `Stock.css`**
|
||||
|
||||
`src/pages/stock/Stock.css` 파일 맨 끝에 추가:
|
||||
|
||||
```css
|
||||
/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */
|
||||
.wl-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wl-form__input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wl-form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.wl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.wl-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wl-row__name { font-size: 14px; }
|
||||
.wl-row__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; }
|
||||
.wl-del {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.wl-period-toggle { display: flex; gap: 4px; }
|
||||
.wl-period {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wl-period.is-active {
|
||||
color: var(--accent-stock);
|
||||
border-color: var(--accent-stock);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.wl-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wl-alert {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wl-alert__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-kind-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wl-alert__name { font-size: 14px; }
|
||||
.wl-alert__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.wl-alert__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: PASS (2 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/components/WatchlistTab.jsx src/pages/stock/components/WatchlistTab.test.jsx src/pages/stock/Stock.css
|
||||
git commit -m "feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `StockTrade`에 탭 등재 + 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/stock/stockUtils.js:152` (TAB 상수 추가)
|
||||
- Modify: `src/pages/stock/StockTrade.jsx` (import·훅·탭 배열·렌더)
|
||||
- Modify: `CLAUDE.md` (API 엔드포인트 테이블 — web-ui 루트가 아닌 `web-ui/CLAUDE.md`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 2·3): `useWatchlist`, `WatchlistTab`
|
||||
- Produces: 없음 (통합 지점, 최종 배선)
|
||||
|
||||
- [ ] **Step 1: Add TAB constant**
|
||||
|
||||
`src/pages/stock/stockUtils.js` 맨 끝(`export const TAB_HOLDINGS_INTEL = 'holdings_intel';` 뒤)에 추가:
|
||||
|
||||
```js
|
||||
export const TAB_WATCHLIST = 'watchlist';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire into StockTrade.jsx**
|
||||
|
||||
`src/pages/stock/StockTrade.jsx` 수정 — 4곳:
|
||||
|
||||
(a) stockUtils import에 `TAB_WATCHLIST` 추가 (기존 import 블록 line 6-10):
|
||||
|
||||
```js
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
|
||||
} from './stockUtils';
|
||||
```
|
||||
|
||||
(b) 탭 컴포넌트 import 추가 (기존 `import HoldingsIntelTab ...` 뒤, line 25 근처):
|
||||
|
||||
```js
|
||||
import HoldingsIntelTab from './components/HoldingsIntelTab';
|
||||
import WatchlistTab from './components/WatchlistTab';
|
||||
```
|
||||
|
||||
(c) 훅 인스턴스화 + `TAB_ORDER`/`tabLabels` 확장. `const [activeTab, ...]` 아래(line 31 근처)와 hooks 블록에 추가:
|
||||
|
||||
```js
|
||||
const wl = useWatchlist();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
|
||||
```
|
||||
|
||||
그리고 파일 상단 hooks import 목록에 훅 import 추가 (line 19 `import useAdvisor ...` 뒤):
|
||||
|
||||
```js
|
||||
import useAdvisor from './hooks/useAdvisor';
|
||||
import useWatchlist from './hooks/useWatchlist';
|
||||
```
|
||||
|
||||
`const wl = useWatchlist();` 는 다른 훅들(`const advisor = useAdvisor({...});`) 뒤에 배치.
|
||||
|
||||
(d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 `: <HoldingsIntelTab />,` 를 다음으로 교체:
|
||||
|
||||
```js
|
||||
content: tabId === TAB_PORTFOLIO
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: tabId === TAB_ADVISOR
|
||||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||||
: tabId === TAB_HOLDINGS_INTEL
|
||||
? <HoldingsIntelTab />
|
||||
: <WatchlistTab wl={wl} />,
|
||||
```
|
||||
|
||||
(e) 데스크탑 탭 버튼 배열에 항목 추가. 기존 `{ id: TAB_HOLDINGS_INTEL, ... }` 항목 뒤에 추가:
|
||||
|
||||
```js
|
||||
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
|
||||
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
|
||||
```
|
||||
|
||||
(f) 데스크탑 조건부 렌더 추가. 기존 `{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}` 뒤에 추가:
|
||||
|
||||
```js
|
||||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `npm run test:run`
|
||||
Expected: PASS — 신규 3개 테스트 파일 포함 전체 통과 (기존 테스트 회귀 없음).
|
||||
|
||||
- [ ] **Step 4: Lint + build**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 신규 파일 관련 에러 0. (기존 코드의 사전 경고는 무시하되, 신규 파일이 새 에러를 만들지 않을 것.)
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 빌드 성공 (`dist/` 생성, 에러 없음).
|
||||
|
||||
- [ ] **Step 5: Manual verification (dev server)**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
브라우저에서 `http://localhost:3007/stock/trade` 접속 → "관심종목" 탭이 데스크탑 탭바(⭐)와 모바일 스와이프에 노출되는지 확인. 종목코드 입력 후 추가 → 목록 반영, 삭제 버튼 동작, 기간 토글(1D/7D/30D) 확인. (BE 미배포 시 알림 패널은 에러/빈 상태로 표시되고 CRUD는 독립 동작해야 함.)
|
||||
|
||||
- [ ] **Step 6: Update `web-ui/CLAUDE.md` API 테이블**
|
||||
|
||||
`CLAUDE.md` (web-ui 프로젝트 루트) 의 "API 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처):
|
||||
|
||||
```markdown
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
```
|
||||
|
||||
그리고 페이지 구조 표의 `/stock/trade` 행 설명에 "(포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭)" 취지를 반영.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/stockUtils.js src/pages/stock/StockTrade.jsx CLAUDE.md
|
||||
git commit -m "feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§10 대비):
|
||||
- §2 계약 4종 → Task 2 (api 헬퍼) ✅
|
||||
- §3 탭 등재 → Task 4 ✅
|
||||
- §4 컴포넌트 구조(훅+자립형 탭+utils) → Task 1/2/3 ✅
|
||||
- §5 API 레이어 → Task 2 ✅
|
||||
- §6 UX(낙관적 갱신·중복 차단·기간 토글·정렬) → Task 2(훅)·Task 3(뷰) ✅
|
||||
- §7 스타일 `wl-*` → Task 3 ✅
|
||||
- §8 테스트 → Task 1(utils)·Task 2(훅)·Task 3(컴포넌트) ✅
|
||||
- §9 완료 기준 → Task 4 Step 3–6 ✅
|
||||
- §10 리스크(방어적 파싱·알림 독립) → `asArray` + `alertError` 분리 ✅
|
||||
|
||||
**Placeholder scan:** 모든 코드/명령/기대출력 구체값 명시. TBD/TODO 없음. ✅
|
||||
|
||||
**Type consistency:** `kindMeta`/`conditionLabel`/`relativeTime`/`normalizeTicker` (Task1) ↔ 훅/컴포넌트 사용처 일치. `useWatchlist` 반환 키(`items/alerts/alertDays/setAlertDays/loading/error/alertError/adding/add/remove/reload`) ↔ `WatchlistTab` prop 사용처 일치. `getWatchlist/addWatchlist/removeWatchlist/getTradeAlerts` (api) ↔ 훅 import 일치. ✅
|
||||
|
||||
**참고 — StockTrade 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 — 설계
|
||||
|
||||
작성일: 2026-06-11
|
||||
대상 repo: `web-ui` (프론트엔드)
|
||||
연관 백엔드: ✅ 완료 (`GET /api/agent-office/activity` 필터 지원, main `2c2828c`)
|
||||
|
||||
## 배경 / 목적
|
||||
|
||||
3개 자율 에이전트(stock 보유종목·insta 발급·lotto 진화)가 모두 도는 상태에서
|
||||
"팀이 무엇을·언제·왜 했나"를 **한 화면에서** 보는 에이전트 횡단 오버사이트(CEO 가시화) 기능.
|
||||
|
||||
현재 web-ui에는 `/lotto/evolver` 탭의 lotto 전용 `LottoActivityTimeline`만 존재.
|
||||
통합 `/activity`(전 에이전트 대상)를 소비하는 횡단 뷰가 없다.
|
||||
|
||||
## 백엔드 응답 shape (라이브 검증 완료)
|
||||
|
||||
```
|
||||
GET /api/agent-office/activity?agent_id=&type=task|log&status=&days=&limit=&offset=
|
||||
→ { items: [...], total: N }
|
||||
```
|
||||
|
||||
- **task item**: `{ type:'task', agent_id, task_id, message, created_at, task_type, status, completed_at, duration_seconds }`
|
||||
- **log item**: `{ type:'log', agent_id, task_id, message, created_at, level }`
|
||||
- `status`는 task 전용(`type=log`에 주면 무시). injection 안전(? 바인딩 + 브랜치 선택).
|
||||
|
||||
검증 메모:
|
||||
- 무필터 `total`이 65,599건 → **기본 `days=7` 필터 필수**(task 기준 110건으로 감소).
|
||||
- `requires_approval` 필드는 **존재하지 않음** → `status:'pending'`을 진행/대기 강조로 처리.
|
||||
- `agent_id` 값이 `AGENT_META` 키(stock/music/insta/realestate/lotto)와 일치 → 색상/이미지 재사용.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
AgentOffice는 단일 화면(TopBar + 3×3 AgentGrid + 우측 패널) 구조.
|
||||
우측 패널은 `selectedAgent` 상태로 분기:
|
||||
- `null` → (기존) `EmptyDetailPanel variant="initial"` → **`ActivityTimeline`으로 교체**
|
||||
- `placeholder-N` → `EmptyDetailPanel variant="placeholder"` (유지)
|
||||
- active agent id → `SidePanel` (유지)
|
||||
|
||||
즉 **에이전트 미선택 시 기본 우측 패널이 횡단 타임라인**이 되고, 그리드와 항상 동시 노출.
|
||||
항목/그리드 클릭으로 해당 에이전트 SidePanel로 전환.
|
||||
|
||||
## 신규/변경 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/api.js` | `agentActivity({agent_id,type,status,days,limit,offset})` 추가 — 빈 값 제외 쿼리스트링 빌드 + GET `/api/agent-office/activity` |
|
||||
| `src/pages/agent-office/AgentOffice.jsx` | `selectedAgent===null` 분기를 `EmptyDetailPanel` → `ActivityTimeline`(props: `refreshTrigger`, `onSelectAgent`)로 교체 |
|
||||
| `src/pages/agent-office/hooks/useActivityFeed.js` | items/offset/total/hasMore/loading/error/filters 상태 관리 |
|
||||
| `src/pages/agent-office/components/ActivityTimeline.jsx` | 컨테이너: 헤더 + `ActivityFilters` + 리스트 + 무한스크롤 sentinel + 상태(loading/empty/error/end) |
|
||||
| `src/pages/agent-office/components/ActivityFilters.jsx` | 필터 4종(agent 색칩 / type / status / days). `type==='log'`일 때 status 비활성 |
|
||||
| `src/pages/agent-office/components/ActivityItem.jsx` | 한 행: agent 색·이미지 + message + 상태/level 뱃지 + 상대시간 + duration. 클릭 → `onSelectAgent(agent_id)` |
|
||||
| `src/pages/agent-office/AgentOffice.css` | 타임라인/필터/항목 스타일 (designer 스킬로 마감) |
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
AgentOffice (selectedAgent===null)
|
||||
└─ <ActivityTimeline refreshTrigger={refreshTrigger} onSelectAgent={handleSelectAgent} />
|
||||
└─ useActivityFeed(filters)
|
||||
• mount / 필터 변경 → offset=0 fetch → items 교체
|
||||
• loadMore (sentinel 교차) → offset += limit → items append
|
||||
• refreshTrigger 변경 → offset=0 재조회 → items 교체 (WS 실시간 연동)
|
||||
└─ ActivityItem onClick → onSelectAgent(agent_id) → SidePanel로 전환
|
||||
```
|
||||
|
||||
`handleSelectAgent`는 기존 콜백 재사용(선택 + `clearNotifications`).
|
||||
|
||||
## 필터 기본값
|
||||
|
||||
`days=7`, `type=all`, `status=all`, `agent=all`, `limit=30`(페이지당).
|
||||
|
||||
## 상태 / 비주얼 매핑
|
||||
|
||||
- task `status`: `succeeded` → 초록 ✓ / `failed` → 빨강 ✗ / `pending`·`working` → 앰버 펄스 ⏳(강조)
|
||||
- log `level`: `error` → ❌ / `warning` → ⚠️ / `info` → ·
|
||||
- agent 색상: `AGENT_META[agent_id].color`, 미지정 agent → 회색 `#6b7280`
|
||||
- `offset >= total` → "더 이상 활동 없음" / 무한스크롤은 IntersectionObserver
|
||||
|
||||
## 상태 처리(엣지)
|
||||
|
||||
- 첫 페이지 로딩 → 스피너/스켈레톤
|
||||
- 빈 결과 → "최근 N일 활동 없음"
|
||||
- fetch 실패 → 인라인 에러 + 재시도 버튼
|
||||
- 리스트 끝 → end-of-list 표시, sentinel 관찰 중단
|
||||
|
||||
## 테스트 (TDD, vitest + RTL — 기존 패턴 따름)
|
||||
|
||||
- `useActivityFeed`: 필터 변경 시 offset 리셋 + items 교체 / loadMore append / refreshTrigger 재조회 / `hasMore = items.length < total` 계산 (api mock)
|
||||
- `ActivityItem`: task vs log 렌더 분기, status/level 뱃지 클래스, 클릭 시 `onSelectAgent(agent_id)` 호출
|
||||
- `ActivityFilters`: `type==='log'`일 때 status select 비활성, 필터 변경 시 onChange 호출
|
||||
|
||||
## 비범위 (YAGNI)
|
||||
|
||||
- 별도 라우트(`/agent-office/activity`) 미생성 — 기본 우측 패널 통합으로 충분
|
||||
- 기존 `getActivityFeed(limit, offset)` 헬퍼는 lotto evolver 등에서 사용 여부 확인 후 유지(신규 `agentActivity`와 공존, 무리한 통합 안 함)
|
||||
- `LottoActivityTimeline`(`kind/ts/payload` shape)은 다른 엔드포인트 소비 → 건드리지 않음
|
||||
- CSV/export, 검색어 필터 등 부가기능 제외
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `agentActivity` api 헬퍼 추가
|
||||
2. `useActivityFeed` 훅 (TDD)
|
||||
3. `ActivityItem` / `ActivityFilters` (TDD)
|
||||
4. `ActivityTimeline` 컨테이너 조립
|
||||
5. `AgentOffice.jsx` 분기 교체
|
||||
6. designer 스킬로 CSS 마감
|
||||
7. lint + 테스트 + 빌드 검증
|
||||
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 관심종목 탭 (Watchlist Tab) — FE 설계
|
||||
|
||||
- **작성일**: 2026-07-03
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **상위 스펙(BE)**: `web-page-backend/docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §2·§5.3
|
||||
- **상위 플랜(BE)**: `web-page-backend/docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md`
|
||||
- **범위**: FE(web-ui)만. BE 계약(§5.3)을 소비하는 "관심종목" 탭 구현. 워커(web-ai)·BE는 별도 세션.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
실시간 매매 알림 시스템의 매수 유니버스는 **"watchlist(사용자 관리) ∪ 당일 스크리너 후보"** 로 정의된다(BE 스펙 §2). 관심종목 관리 수단은 **"텔레그램 봇 명령 + web-ui 탭 둘 다"** 로 결정되었다. 본 문서는 그중 **web-ui 탭**을 정의한다.
|
||||
|
||||
목표:
|
||||
1. 사용자가 관심종목을 웹에서 추가/조회/삭제(CRUD)할 수 있다.
|
||||
2. 최근 발생한 매수·매도 시그널 알림 이력을 웹에서 확인할 수 있다.
|
||||
|
||||
비목표(YAGNI, v1 제외):
|
||||
- 종목별 조건 오버라이드(`params_json`: trailing_pct, stop_pct 등) 편집 — BE POST/PUT params 계약 미확정.
|
||||
- 실시간 WebSocket 알림 스트림 — 폴링/수동 새로고침으로 충분.
|
||||
- 텔레그램 설정 UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. 소비할 BE 계약 (§5.3)
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/stock/watchlist` | — | `{ watchlist: [{ ticker, name, note, params, added_at }] }` |
|
||||
| POST | `/api/stock/watchlist` | `{ ticker, name?, note? }` | 201 `{ ok: true }` |
|
||||
| DELETE | `/api/stock/watchlist/{ticker}` | — | 200 / 404 |
|
||||
| GET | `/api/stock/trade-alerts?days=N` | — | `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` |
|
||||
|
||||
**알림 필드 enum (BE 스펙 §5.3):**
|
||||
|
||||
- `kind`: `buy` | `sell`
|
||||
- `condition` (buy): `buy_ma20_pullback` · `buy_breakout` · `buy_rsi_bounce`
|
||||
- `condition` (sell): `sell_stop_loss` · `sell_ma_break` · `sell_take_profit` · `sell_climax` · `sell_trailing_stop`
|
||||
|
||||
> 응답 래핑 키(`watchlist`/`alerts`)와 `params` 필드는 BE 스펙 문구 기준. FE는 방어적으로 파싱한다(배열 직접 반환 / 래핑 둘 다 허용, `params` 미사용이면 무시).
|
||||
|
||||
---
|
||||
|
||||
## 3. 배치 & 탭 등재
|
||||
|
||||
`/stock/trade` (거래 데스크)에 5번째 메인 탭 **"관심종목"** 추가. 기존 탭 등재 패턴을 그대로 확장한다.
|
||||
|
||||
- `src/pages/stock/stockUtils.js`: `export const TAB_WATCHLIST = 'watchlist';`
|
||||
- `src/pages/stock/StockTrade.jsx`:
|
||||
- `TAB_ORDER` 배열에 `TAB_WATCHLIST` 추가
|
||||
- `tabLabels` 에 `'관심종목'` 추가
|
||||
- 데스크탑 탭 버튼 배열에 `{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: <count> }` 추가
|
||||
- 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가
|
||||
- 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && <WatchlistTab />}` 추가
|
||||
- 탭 뱃지 = 관심종목 개수(훅에서 노출).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 구조 (접근안 A: 훅 + 자립형 탭)
|
||||
|
||||
기존 `HoldingsIntelTab` 패턴(자립형 탭 컴포넌트 + api 헬퍼)에 상태 로직을 훅으로 분리한 형태.
|
||||
|
||||
```
|
||||
src/pages/stock/
|
||||
├── hooks/
|
||||
│ └── useWatchlist.js # CRUD + 알림 이력 상태·액션
|
||||
├── components/
|
||||
│ └── WatchlistTab.jsx # 표현 (내부 소형 컴포넌트: WatchlistForm/Row, AlertCard)
|
||||
├── watchlistUtils.js # 순수 헬퍼 (라벨/색/시간 매핑)
|
||||
└── watchlistUtils.test.js # 헬퍼 유닛 테스트
|
||||
```
|
||||
|
||||
### 4.1 `useWatchlist.js` (훅)
|
||||
|
||||
상태:
|
||||
- `items: []` — 관심종목 목록
|
||||
- `alerts: []` — 알림 이력
|
||||
- `alertDays: 7` — 알림 기간 필터(1/7/30)
|
||||
- `loading`, `error`, `adding` (폼 제출 중)
|
||||
|
||||
액션:
|
||||
- `load()` — `getWatchlist()` + `getTradeAlerts(alertDays)` 병렬 로드
|
||||
- `add({ ticker, name, note })` — 낙관적 추가 → 성공 시 `load()` 재조회, 실패 시 롤백 + 에러
|
||||
- `remove(ticker)` — 낙관적 제거 → 실패 시 롤백
|
||||
- `setAlertDays(days)` — 변경 시 알림만 재조회
|
||||
|
||||
노출: `{ items, alerts, alertDays, setAlertDays, loading, error, adding, add, remove, load }`
|
||||
|
||||
### 4.2 `WatchlistTab.jsx` (표현)
|
||||
|
||||
- 마운트 시 `load()`.
|
||||
- **상단 패널 — 관심종목 관리**: 인라인 추가 폼(ticker 필수, name·note 선택) + 목록. 각 행: 종목명/코드/메모/등록일 + 삭제 버튼. 빈 상태 안내.
|
||||
- **하단 패널 — 최근 시그널**: 기간 토글(1D/7D/30D) + 알림 카드. 카드: `kind` 뱃지, `condition` 한글 라벨, `ticker`/`name`, `price`, `detail`, `fired_at` 상대시간.
|
||||
- 로딩/에러/빈 상태: `stock-panel` · `stock-error` · `stock-empty` 등 기존 클래스 재사용.
|
||||
- 하단 면책 문구(`hi-disclaimer` 유사): "※ 어드바이저리 알림이며 자동매매가 아닙니다."
|
||||
|
||||
### 4.3 `watchlistUtils.js` (순수 헬퍼 — 테스트 대상)
|
||||
|
||||
```js
|
||||
KIND_META = { buy: { label: '매수', color, bg }, sell: { label: '매도', color, bg } }
|
||||
CONDITION_LABEL = { buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등', sell_stop_loss: '손절 라인', sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱' }
|
||||
|
||||
kindMeta(kind) // 미정의 → 회색 폴백 + 원문 label
|
||||
conditionLabel(cond) // 미정의 → 원문 그대로 반환
|
||||
normalizeTicker(str) // trim만 수행(한국 종목코드=6자리 숫자, 대문자화 불필요)
|
||||
relativeTime(iso) // '3분 전' / '2시간 전' / '어제' 등, 잘못된 값 → '' 폴백
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 레이어 (`src/api.js` 추가)
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts ──
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); // { ticker, name?, note? }
|
||||
export const removeWatchlist= (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7)=> apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
전부 상대경로, 기존 `apiGet/apiPost/apiDelete` 재사용. `getWatchlist`/`getTradeAlerts` 응답은 훅에서 `data.watchlist ?? data ?? []`, `data.alerts ?? data ?? []` 로 방어적 파싱.
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / 상호작용 세부
|
||||
|
||||
- **추가 폼**: ticker 미입력 시 제출 비활성. 제출 중 `adding` → 버튼 로딩. 성공 시 폼 초기화.
|
||||
- **낙관적 갱신**: add/remove 즉시 UI 반영, 실패 시 이전 상태 롤백 + `stock-error` 메시지.
|
||||
- **중복 방지**: 이미 목록에 있는 ticker면 폼에서 안내(추가 차단).
|
||||
- **알림 카드 정렬**: `fired_at` 내림차순(최신 우선).
|
||||
- **빈 상태**: 관심종목 0개 / 알림 0개 각각 안내 문구.
|
||||
- **반응형**: 데스크탑 2열/모바일 1열은 기존 `stock-panel` 그리드 관례 따름.
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
`src/pages/stock/Stock.css` 하단에 `wl-*` 프리픽스 섹션 추가 (기존 `hi-*` 패턴과 동일 구성):
|
||||
- `.wl-form`, `.wl-list`, `.wl-row`, `.wl-row__meta`, `.wl-del`
|
||||
- `.wl-alerts`, `.wl-alert`, `.wl-kind-badge`, `.wl-cond`, `.wl-period-toggle`
|
||||
- 색상: 매수 초록 `#22c55e`, 매도 빨강 `#ef4444` (기존 `ACTION_MAP` 팔레트와 일치).
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 (TDD)
|
||||
|
||||
`watchlistUtils.test.js` — 순수 헬퍼 검증:
|
||||
1. `conditionLabel`: 정의된 8종 매핑 정확, 미정의 값은 원문 폴백.
|
||||
2. `kindMeta`: buy/sell 라벨·색, 미정의 kind 회색 폴백.
|
||||
3. `relativeTime`: 방금/분/시간/일 경계, 잘못된 입력 `''` 폴백.
|
||||
4. `normalizeTicker`: 공백 trim.
|
||||
|
||||
컴포넌트/훅은 수동 검증(개발 서버 3007 + BE 계약) + 빌드/lint 통과로 확인. (기존 스크리너 훅 테스트처럼 필요 시 훅 테스트 추가 가능하나 v1 필수 아님.)
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] 거래 데스크에 "관심종목" 탭 노출(데스크탑·모바일), 뱃지에 개수 표시.
|
||||
- [ ] 종목 추가/삭제가 BE 계약대로 동작(낙관적 갱신 + 실패 롤백).
|
||||
- [ ] 최근 알림 이력이 기간 토글별로 조회되고, kind/condition 한글 라벨·색으로 표시.
|
||||
- [ ] `watchlistUtils.test.js` 통과.
|
||||
- [ ] `npm run lint` · `npm run build` 통과.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 / 오픈 이슈
|
||||
|
||||
- **응답 래핑 형태 미확정**: BE가 `{ watchlist: [...] }` 인지 배열 직접인지 문구 기준 불확실 → 방어적 파싱으로 흡수.
|
||||
- **알림 엔드포인트 미배포 가능성**: BE 세션 미완 시 GET `/api/stock/trade-alerts` 404/네트워크 오류 → 알림 패널은 에러 상태를 조용히 표시하고 관심종목 CRUD는 독립 동작하도록 분리.
|
||||
- **params 편집**: v1 제외. 추후 BE POST/PUT params 계약 확정 후 별도 스펙으로 확장.
|
||||
10
index.html
10
index.html
@@ -5,9 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>가후습 개인기록</title>
|
||||
<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=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
|
||||
<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=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<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"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
public/images/saju/horyung/background.png
Normal file
BIN
public/images/saju/horyung/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/saju/horyung/horyung-head.png
Normal file
BIN
public/images/saju/horyung/horyung-head.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
BIN
public/images/saju/horyung/horyung-main.png
Normal file
BIN
public/images/saju/horyung/horyung-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/images/saju/horyung/horyung-upper.png
Normal file
BIN
public/images/saju/horyung/horyung-upper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 929 KiB |
26
src/App.css
26
src/App.css
@@ -25,6 +25,16 @@
|
||||
margin-left: var(--sidebar-w);
|
||||
}
|
||||
|
||||
.app-shell--immersive {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #F7F2E8;
|
||||
}
|
||||
|
||||
.app-content--immersive {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
|
||||
|
||||
.app-topbar {
|
||||
@@ -59,6 +69,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.site-main--immersive {
|
||||
padding: 0;
|
||||
background: #F7F2E8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-main {
|
||||
padding: 16px;
|
||||
@@ -491,6 +506,17 @@
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.app-shell--immersive {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-main--immersive {
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */
|
||||
|
||||
16
src/App.jsx
16
src/App.jsx
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PageHeader from './components/PageHeader';
|
||||
@@ -9,19 +9,21 @@ import './App.css';
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
const { pathname } = useLocation();
|
||||
const isImmersiveRoute = pathname.startsWith('/saju');
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Navbar />
|
||||
<div className="app-content">
|
||||
<main className="site-main">
|
||||
<PageHeader />
|
||||
<div className={`app-shell${isImmersiveRoute ? ' app-shell--immersive' : ''}`}>
|
||||
{!isImmersiveRoute && <Navbar />}
|
||||
<div className={`app-content${isImmersiveRoute ? ' app-content--immersive' : ''}`}>
|
||||
<main className={`site-main${isImmersiveRoute ? ' site-main--immersive' : ''}`}>
|
||||
{!isImmersiveRoute && <PageHeader />}
|
||||
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||
<Outlet />
|
||||
</React.Suspense>
|
||||
</main>
|
||||
</div>
|
||||
{isMobile && <BottomNav />}
|
||||
{isMobile && !isImmersiveRoute && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/api.js
37
src/api.js
@@ -14,6 +14,11 @@ export async function apiGet(path) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드)
|
||||
export async function getNodeStatus() {
|
||||
return apiGet("/api/agent-office/nodes");
|
||||
}
|
||||
|
||||
export async function apiDelete(path) {
|
||||
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
@@ -548,6 +553,8 @@ export function getInstaAssetUrl(slateId, page) {
|
||||
return `/api/insta/slates/${slateId}/assets/${page}`;
|
||||
}
|
||||
|
||||
export const instaPackageUrl = (slateId) => `/api/insta/slates/${slateId}/package`;
|
||||
|
||||
export function getInstaTask(taskId) {
|
||||
return apiGet(`/api/insta/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
@@ -592,6 +599,17 @@ export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/age
|
||||
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||
export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (agent_id) p.set('agent_id', agent_id);
|
||||
if (type) p.set('type', type);
|
||||
if (status) p.set('status', status);
|
||||
if (days) p.set('days', String(days));
|
||||
p.set('limit', String(limit));
|
||||
p.set('offset', String(offset));
|
||||
return apiGet(`/api/agent-office/activity?${p.toString()}`);
|
||||
};
|
||||
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||
|
||||
// --- Lotto Briefing ---
|
||||
@@ -697,6 +715,10 @@ export const refreshScreenerSnap = () => apiPost('/api/stock/screener
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
|
||||
// ---- Stock Holdings Intelligence ----
|
||||
export const stockHoldingsIntel = () => apiGet('/api/stock/holdings/intel');
|
||||
export const stockHoldingsHistory = (ticker, days = 30) => apiGet(`/api/stock/holdings/intel/history?ticker=${ticker}&days=${days}`);
|
||||
|
||||
// --- Lotto Weight Evolver ---
|
||||
|
||||
export async function fetchEvolverStatus() {
|
||||
@@ -740,6 +762,11 @@ export async function triggerEvolverEvaluate() {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// --- Lotto Backtest ---
|
||||
export const lottoBacktestTrackRecord = () => apiGet('/api/lotto/backtest/track-record');
|
||||
export const lottoBacktestCalibration = (weeks=52) => apiGet(`/api/lotto/backtest/calibration?weeks=${weeks}`);
|
||||
export const lottoBacktestReview = (drawNo) => apiGet(`/api/lotto/backtest/review/${drawNo}`);
|
||||
|
||||
// --- Tarot Lab ---
|
||||
|
||||
export function tarotInterpret(body) {
|
||||
@@ -825,3 +852,13 @@ export function compatPatchReading(id, body) {
|
||||
export function compatDeleteReading(id) {
|
||||
return apiDelete(`/api/saju/compat/readings/${id}`);
|
||||
}
|
||||
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
|
||||
@@ -371,6 +371,18 @@
|
||||
.ao-log-level { min-width: 48px; font-weight: bold; }
|
||||
.ao-log-msg { color: #ccc; word-break: break-all; }
|
||||
|
||||
.ao-log-source {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ao-log-meta {
|
||||
color: #6b7280;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ===== Common ===== */
|
||||
.ao-empty {
|
||||
color: #94a3b8;
|
||||
@@ -435,3 +447,102 @@
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 횡단 오버사이트 타임라인 (mission-control activity log) ── */
|
||||
.ao-activity { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
|
||||
/* 헤더 — 섹션 타이틀 톤 (퍼플 액센트 + 트래킹) */
|
||||
.ao-activity-header { align-items: center; }
|
||||
.ao-activity-header .ao-sidepanel-name {
|
||||
color: #8b5cf6; letter-spacing: 0.6px; text-transform: uppercase; font-size: 13px;
|
||||
}
|
||||
|
||||
/* 필터 바 — 다크 슬레이트 셀렉트 */
|
||||
.ao-activity-filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #333;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
.ao-activity-select {
|
||||
background: #1e293b; color: #e2e8f0;
|
||||
border: 1px solid #334155; border-radius: 4px;
|
||||
padding: 4px 8px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.ao-activity-select:hover { border-color: #475569; }
|
||||
.ao-activity-select:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); }
|
||||
.ao-activity-select:disabled { opacity: .35; cursor: not-allowed; }
|
||||
|
||||
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; padding: 0; }
|
||||
|
||||
/* 활동 행 — 타임라인 스파인(수직 레일) + 신호등 도트 */
|
||||
.ao-activity-item {
|
||||
position: relative;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #1a2233;
|
||||
cursor: pointer; transition: background .12s;
|
||||
animation: ao-activity-in .18s ease-out both;
|
||||
}
|
||||
.ao-activity-item::before {
|
||||
content: ''; position: absolute; left: 16px; top: 0; bottom: 0;
|
||||
width: 1px; background: #1e293b; z-index: 0;
|
||||
}
|
||||
.ao-activity-item:hover { background: #161b2e; }
|
||||
.ao-activity-item:focus-visible { outline: none; background: #161b2e; box-shadow: inset 2px 0 0 #8b5cf6; }
|
||||
|
||||
/* 진행/대기 강조 — 앰버 인셋 + 도트 펄스 */
|
||||
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, 0.06); box-shadow: inset 2px 0 0 #f59e0b; }
|
||||
.ao-activity-item.is-highlight .ao-activity-dot { animation: ao-pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* 에이전트 색 = 신호등. 링(#111)으로 뒤 레일을 끊어 점처럼 떠 보이게 */
|
||||
.ao-activity-dot {
|
||||
position: relative; z-index: 1; flex: 0 0 auto;
|
||||
width: 9px; height: 9px; border-radius: 50%; margin-top: 4px;
|
||||
box-shadow: 0 0 0 3px #111;
|
||||
}
|
||||
|
||||
.ao-activity-body { flex: 1; min-width: 0; }
|
||||
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||
.ao-activity-agent { font-size: 11px; font-weight: bold; letter-spacing: 0.3px; }
|
||||
|
||||
/* 상태 뱃지 — 터미널 톤(각진 모서리, 모노) */
|
||||
.ao-activity-badge {
|
||||
font-size: 10px; font-weight: bold; letter-spacing: 0.3px;
|
||||
padding: 1px 7px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 로그 레벨 표식 */
|
||||
.ao-activity-level { font-size: 12px; line-height: 1; }
|
||||
.ao-activity-level.level-info { color: #475569; font-size: 15px; font-weight: bold; }
|
||||
.ao-activity-level.level-warning,
|
||||
.ao-activity-level.level-error { font-size: 12px; }
|
||||
|
||||
.ao-activity-msg {
|
||||
font-size: 12.5px; color: #cbd5e1; margin-top: 3px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ao-activity-item.is-log .ao-activity-msg { color: #94a3b8; }
|
||||
|
||||
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.ao-activity-time { font-size: 10px; color: #64748b; }
|
||||
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||
|
||||
.ao-activity-loading,
|
||||
.ao-activity-end {
|
||||
text-align: center; padding: 12px; font-size: 10px;
|
||||
color: #475569; letter-spacing: 0.6px; text-transform: uppercase;
|
||||
}
|
||||
.ao-activity-sentinel { height: 1px; }
|
||||
|
||||
.ao-activity-error { padding: 12px; font-size: 12px; color: #fca5a5; }
|
||||
.ao-activity-error button {
|
||||
margin-left: 8px; background: #2a2a4e; color: #8b5cf6;
|
||||
border: 1px solid #4c1d95; border-radius: 4px;
|
||||
padding: 3px 10px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.ao-activity-error button:hover { background: #3a3a5e; }
|
||||
|
||||
@keyframes ao-activity-in {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import TopBar from './components/TopBar.jsx';
|
||||
import AgentGrid from './components/AgentGrid.jsx';
|
||||
import SidePanel from './components/SidePanel.jsx';
|
||||
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
|
||||
import ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||
import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
@@ -36,7 +37,12 @@ export default function AgentOffice() {
|
||||
|
||||
let rightPanel;
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
|
||||
} else {
|
||||
|
||||
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/pages/agent-office/components/ActivityFilters.jsx
|
||||
import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'task', label: 'Task' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'succeeded', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'pending', label: '대기' },
|
||||
];
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1일' },
|
||||
{ value: 7, label: '7일' },
|
||||
{ value: 30, label: '30일' },
|
||||
];
|
||||
|
||||
export default function ActivityFilters({ filters, onChange }) {
|
||||
const set = (patch) => onChange({ ...filters, ...patch });
|
||||
const statusDisabled = filters.type === 'log';
|
||||
return (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
|
||||
const base = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
describe('ActivityFilters', () => {
|
||||
it('type=log이면 상태 필터가 비활성화된다', () => {
|
||||
render(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||
});
|
||||
});
|
||||
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/pages/agent-office/components/ActivityItem.jsx
|
||||
import { AGENT_META } from '../constants.js';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' },
|
||||
failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' },
|
||||
working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' },
|
||||
pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' },
|
||||
};
|
||||
|
||||
const LEVEL_STYLE = {
|
||||
error: { icon: '❌', cls: 'level-error' },
|
||||
warning: { icon: '⚠️', cls: 'level-warning' },
|
||||
info: { icon: '·', cls: 'level-info' },
|
||||
};
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||
return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ item, onSelectAgent }) {
|
||||
const meta = AGENT_META[item.agent_id];
|
||||
const color = meta?.color || '#6b7280';
|
||||
const name = meta?.displayName || item.agent_id;
|
||||
const isTask = item.type === 'task';
|
||||
const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending;
|
||||
const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info;
|
||||
const highlight = isTask && (item.status === 'pending' || item.status === 'working');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
describe('ActivityItem', () => {
|
||||
it('task 항목은 상태 뱃지와 duration을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '120px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||
|
||||
// jsdom IntersectionObserver stub
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
describe('ActivityTimeline', () => {
|
||||
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,42 @@ import { getAgentLogs } from '../../../api';
|
||||
const LEVEL_STYLE = {
|
||||
info: { color: '#60a5fa' },
|
||||
warning: { color: '#fbbf24' },
|
||||
error: { color: '#ef4444' }
|
||||
error: { color: '#ef4444' },
|
||||
};
|
||||
|
||||
const SOURCE_STYLE = {
|
||||
agent: { color: '#9ca3af', label: 'AGENT' },
|
||||
access: { color: '#5eead4', label: 'ACCESS' },
|
||||
log: { color: '#a78bfa', label: 'LOG' },
|
||||
};
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function LogTab({ agentId, refreshTrigger }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAgentLogs(agentId, 50).then(data => {
|
||||
if (!cancelled) setLogs(Array.isArray(data) ? data : (data?.logs || []));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
const fetchLogs = () => {
|
||||
getAgentLogs(agentId, 100).then(data => {
|
||||
if (cancelled) return;
|
||||
setLogs(Array.isArray(data) ? data : (data?.logs || []));
|
||||
}).catch(() => {});
|
||||
};
|
||||
fetchLogs();
|
||||
const interval = setInterval(fetchLogs, 5000); // 5초 폴링
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [agentId, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
@@ -30,13 +48,23 @@ export default function LogTab({ agentId, refreshTrigger }) {
|
||||
<div className="ao-log-tab" ref={scrollRef}>
|
||||
{logs.length === 0 && <div className="ao-empty">No logs yet</div>}
|
||||
{logs.map((log, i) => {
|
||||
const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const source = log.source || 'agent';
|
||||
const sourceMeta = SOURCE_STYLE[source] || SOURCE_STYLE.agent;
|
||||
const levelStyle = LEVEL_STYLE[log.level] || LEVEL_STYLE.info;
|
||||
const time = formatTime(log.ts || log.created_at);
|
||||
return (
|
||||
<div key={log.id || i} className="ao-log-item">
|
||||
<div key={log.id || `${source}-${i}-${time}`} className="ao-log-item">
|
||||
<span className="ao-log-time">{time}</span>
|
||||
<span className="ao-log-level" style={style}>[{log.level}]</span>
|
||||
<span className="ao-log-source" style={{ color: sourceMeta.color }}>
|
||||
[{sourceMeta.label}]
|
||||
</span>
|
||||
<span className="ao-log-level" style={levelStyle}>[{log.level}]</span>
|
||||
<span className="ao-log-msg">{log.message}</span>
|
||||
{source === 'access' && (
|
||||
<span className="ao-log-meta">
|
||||
{' '}({log.status} · {log.ms}ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { agentActivity } from '../../../api';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const offsetRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
const fetchPage = useCallback(async (offset, replace) => {
|
||||
// append(loadMore)만 중복 방지. replace(필터/refresh 재조회)는 항상 우선 진행.
|
||||
if (!replace && loadingRef.current) return;
|
||||
const reqId = ++requestIdRef.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||
if (reqId !== requestIdRef.current) return; // 더 새로운 요청이 시작됨 → stale 응답 무시
|
||||
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||
setTotal(data?.total || 0);
|
||||
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||
offsetRef.current = offset + newItems.length;
|
||||
} catch (e) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setError(e.message || '불러오기 실패');
|
||||
} finally {
|
||||
if (reqId === requestIdRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [filterKey, refreshTrigger, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingRef.current) return;
|
||||
if (offsetRef.current >= total) return;
|
||||
fetchPage(offsetRef.current, false);
|
||||
}, [fetchPage, total]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
offsetRef.current = 0;
|
||||
fetchPage(0, true);
|
||||
}, [fetchPage]);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||
}
|
||||
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useActivityFeed } from './useActivityFeed.js';
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => mockAgentActivity.mockReset());
|
||||
|
||||
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||
|
||||
describe('useActivityFeed', () => {
|
||||
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
|
||||
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { result.current.loadMore(); });
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||
});
|
||||
|
||||
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||
mockAgentActivity
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||
rerender({ rt: 1 });
|
||||
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('hasMore는 items.length < total', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('필터 변경 중이던 이전(stale) 요청 응답은 무시된다', async () => {
|
||||
let resolveFirst;
|
||||
const firstPromise = new Promise(r => { resolveFirst = r; });
|
||||
mockAgentActivity
|
||||
.mockReturnValueOnce(firstPromise) // 초기 요청 — 느리게 resolve
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'fresh', agent_id: 'insta' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } }); // 첫 요청 resolve 전에 필터 변경
|
||||
await waitFor(() => expect(result.current.items[0]?.task_id).toBe('fresh'));
|
||||
await act(async () => { resolveFirst({ items: [item({ task_id: 'stale' })], total: 99 }); });
|
||||
expect(result.current.items[0].task_id).toBe('fresh'); // stale이 덮어쓰지 않음
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
});
|
||||
359
src/pages/infra/InfraMonitor.css
Normal file
359
src/pages/infra/InfraMonitor.css
Normal file
@@ -0,0 +1,359 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
InfraMonitor — NAS↔Windows 워커 파이프라인 관측 콘솔
|
||||
다크 미션컨트롤 / 텔레메트리 미학 (index.css 토큰 재사용)
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.infra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 상태 바 ───────────────────────────────────────────────────────── */
|
||||
.infra-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-bar__stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-chip {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-chip b {
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
.infra-chip.is-ok {
|
||||
color: #00d4ff;
|
||||
border-color: rgba(0, 212, 255, 0.35);
|
||||
box-shadow: 0 0 16px rgba(0, 212, 255, 0.12) inset;
|
||||
}
|
||||
.infra-chip.is-warn {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.infra-chip.is-danger {
|
||||
color: #fb923c;
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
.infra-chip.is-down {
|
||||
color: #f43f5e;
|
||||
border-color: rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.1) inset;
|
||||
}
|
||||
|
||||
.infra-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.infra-updated {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.infra-toggle button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-toggle button.is-active {
|
||||
background: var(--neon-cyan-muted);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.infra-refresh {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-refresh:hover {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--line-bright);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ── 에러 / 경고 / 로딩 ────────────────────────────────────────────── */
|
||||
.infra-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
}
|
||||
.infra-error b {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-error span {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
.infra-error button {
|
||||
background: rgba(244, 63, 94, 0.18);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #ffd2da;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.infra-warn-banner {
|
||||
padding: 12px 18px;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: #ffb3bf;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.infra-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── 3D 스테이지 ───────────────────────────────────────────────────── */
|
||||
.infra-stage {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 90% 60% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(139, 92, 246, 0.07) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #060a16 0%, #04060f 100%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.infra-stage::before {
|
||||
/* 미세 그리드 텍스처 */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(0, 212, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 100% 80% at 50% 50%, #000 40%, transparent 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pipeline-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 58vh;
|
||||
min-height: 440px;
|
||||
}
|
||||
.pipeline-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pipeline-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
background: rgba(6, 10, 22, 0.78);
|
||||
border: 1px solid color-mix(in srgb, var(--pl-color, #00d4ff) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.pipeline-label .pl-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--pl-color, #00d4ff);
|
||||
box-shadow: 0 0 8px var(--pl-color, #00d4ff);
|
||||
}
|
||||
.pipeline-label .pl-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.pipeline-label .pl-state {
|
||||
font-size: 10.5px;
|
||||
color: var(--pl-color, #8892b0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pipeline-label--anchor .pl-name {
|
||||
color: var(--pl-color, #e8f0fe);
|
||||
}
|
||||
|
||||
.infra-legend {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 12px;
|
||||
background: rgba(6, 10, 22, 0.6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.infra-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.infra-legend i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── 워커 카드 그리드 ──────────────────────────────────────────────── */
|
||||
.infra-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.infra-grid--compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
}
|
||||
.infra-card {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--c, #4a5572);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s var(--ease-out), border-color 0.2s;
|
||||
}
|
||||
.infra-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--c) 40%, var(--line));
|
||||
}
|
||||
.infra-card--down {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.infra-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.infra-card__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
box-shadow: 0 0 10px var(--c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.infra-card__id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.infra-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.infra-card__kind {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.infra-card__state {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--c);
|
||||
padding: 3px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.infra-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 7px 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.infra-metric__v {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-metric__l {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.infra-metric--warn .infra-metric__v {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.infra-metric--danger .infra-metric__v {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-card__foot {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pipeline-canvas {
|
||||
height: 46vh;
|
||||
min-height: 340px;
|
||||
}
|
||||
.infra-bar {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
141
src/pages/infra/InfraMonitor.jsx
Normal file
141
src/pages/infra/InfraMonitor.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// src/pages/infra/InfraMonitor.jsx
|
||||
// /infra — NAS↔Windows 분산 워커 파이프라인 실시간 관측.
|
||||
// 3D 파이프라인(Three.js) + 2D 워커 카드. WebGL 미지원 시 카드만.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import PipelineScene from './PipelineScene';
|
||||
import { workerStateLabel, workerColor, workerTitle, kindLabel } from './statusVisual';
|
||||
import './InfraMonitor.css';
|
||||
|
||||
function hasWebGL() {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl')));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }) {
|
||||
return (
|
||||
<div className={`infra-metric${tone ? ` infra-metric--${tone}` : ''}`}>
|
||||
<span className="infra-metric__v">{value ?? 0}</span>
|
||||
<span className="infra-metric__l">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerCard({ w }) {
|
||||
const color = workerColor(w);
|
||||
return (
|
||||
<div className={`infra-card${w.alive ? '' : ' infra-card--down'}`} style={{ '--c': color }}>
|
||||
<div className="infra-card__head">
|
||||
<span className="infra-card__dot" />
|
||||
<div className="infra-card__id">
|
||||
<div className="infra-card__title">{workerTitle(w.name)}</div>
|
||||
<div className="infra-card__kind">{kindLabel(w.kind)}</div>
|
||||
</div>
|
||||
<span className="infra-card__state">{workerStateLabel(w)}</span>
|
||||
</div>
|
||||
<div className="infra-card__metrics">
|
||||
<Metric label="큐" value={w.queue_depth} tone={w.queue_depth > 0 ? 'warn' : null} />
|
||||
<Metric label="실패" value={w.dead_letter} tone={w.dead_letter > 0 ? 'danger' : null} />
|
||||
<Metric label="처리중" value={w.processing} />
|
||||
<Metric label="완료" value={w.jobs_done} />
|
||||
</div>
|
||||
<div className="infra-card__foot">
|
||||
{w.alive
|
||||
? `last beat ${w.last_beat_age_s ?? '?'}s 전`
|
||||
: '비콘 없음 (오프라인)'}
|
||||
{w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InfraMonitor() {
|
||||
const { data, error, loading, updatedAt, refresh } = useNodeStatus(3000);
|
||||
const webgl = useMemo(() => hasWebGL(), []);
|
||||
const [view, setView] = useState(webgl ? '3d' : 'grid');
|
||||
|
||||
const workers = data?.workers || [];
|
||||
const online = workers.filter((w) => w.alive).length;
|
||||
const total = workers.length;
|
||||
const deadLetters = workers.reduce((a, w) => a + (w.dead_letter || 0), 0);
|
||||
const redisOk = data ? data.redis_ok : null;
|
||||
|
||||
return (
|
||||
<div className="infra">
|
||||
<div className="infra-bar">
|
||||
<div className="infra-bar__stats">
|
||||
<span className={`infra-chip ${online === total && total > 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}>
|
||||
<b>{online}</b>/{total || '–'} 온라인
|
||||
</span>
|
||||
<span className={`infra-chip ${redisOk === false ? 'is-down' : redisOk ? 'is-ok' : ''}`}>
|
||||
Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'}
|
||||
</span>
|
||||
{data?.paused && (
|
||||
<span className="infra-chip is-warn">
|
||||
⏸ 일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{deadLetters > 0 && <span className="infra-chip is-danger">❌ 실패 {deadLetters}</span>}
|
||||
</div>
|
||||
<div className="infra-bar__actions">
|
||||
{updatedAt && (
|
||||
<span className="infra-updated">
|
||||
{new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신
|
||||
</span>
|
||||
)}
|
||||
{webgl && (
|
||||
<div className="infra-toggle">
|
||||
<button className={view === '3d' ? 'is-active' : ''} onClick={() => setView('3d')}>
|
||||
3D
|
||||
</button>
|
||||
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>
|
||||
그리드
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="infra-refresh" onClick={refresh} title="새로고침">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !data && (
|
||||
<div className="infra-error">
|
||||
<b>집계 서버 연결 끊김</b>
|
||||
<span>{String(error.message || error)}</span>
|
||||
<button onClick={refresh}>다시 시도</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redisOk === false && (
|
||||
<div className="infra-warn-banner">
|
||||
⚠ Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 수 없습니다. 파이프라인이 전면 중단 상태입니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !data && <div className="infra-loading">노드 상태 수집 중…</div>}
|
||||
|
||||
{view === '3d' && webgl && (
|
||||
<div className="infra-stage">
|
||||
<PipelineScene status={data} />
|
||||
<div className="infra-legend">
|
||||
<span><i style={{ background: '#00d4ff' }} /> 정상·흐름</span>
|
||||
<span><i style={{ background: '#fbbf24' }} /> 일시정지</span>
|
||||
<span><i style={{ background: '#fb923c' }} /> 실패누적</span>
|
||||
<span><i style={{ background: '#f43f5e' }} /> 다운·끊김</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`infra-grid${view === '3d' ? ' infra-grid--compact' : ''}`}>
|
||||
{workers.map((w) => (
|
||||
<WorkerCard key={w.name} w={w} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getNodeStatus } from '../../api';
|
||||
import InfraMonitor from './InfraMonitor';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
const sample = {
|
||||
redis_ok: true,
|
||||
paused: false,
|
||||
paused_reason: null,
|
||||
workers: [
|
||||
{ name: 'image-render', kind: 'render', alive: true, state: 'idle', queue_depth: 0, dead_letter: 0, processing: 0, jobs_done: 5, jobs_failed: 0, last_beat_age_s: 3 },
|
||||
{ name: 'insta-render', kind: 'render', alive: false, state: null, queue_depth: 3, dead_letter: 0, processing: 0, jobs_done: 0, jobs_failed: 0, last_beat_age_s: null },
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
|
||||
describe('InfraMonitor', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders worker cards from /nodes (grid mode in jsdom — no WebGL)', async () => {
|
||||
getNodeStatus.mockResolvedValue(sample);
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('Image Render')).toBeInTheDocument());
|
||||
expect(screen.getByText('Insta Render')).toBeInTheDocument();
|
||||
// alive 워커(image-render, idle)는 '대기' 상태 라벨
|
||||
expect(screen.getByText('대기')).toBeInTheDocument();
|
||||
// 오프라인 워커(insta-render)는 '오프라인' 라벨
|
||||
expect(screen.getByText('오프라인')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when /nodes fails', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('down'));
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
340
src/pages/infra/PipelineScene.jsx
Normal file
340
src/pages/infra/PipelineScene.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// src/pages/infra/PipelineScene.jsx
|
||||
// NAS ↔ Redis 큐 버스 ↔ Windows 워커 6종을 raw three.js로 그린 실시간 파이프라인.
|
||||
// 정상: 시안 파티클이 흐름 / busy: 빠르게 / paused: 앰버 정지 / degraded: 주황 흐름 / down: 빨강·흐름 멈춤.
|
||||
// status(/nodes)는 statusRef로 RAF 루프에 최신값 주입. 라벨은 3D→화면 투영 HTML 오버레이.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { linkColor, workerStatus, workerStateLabel, workerTitle } from './statusVisual';
|
||||
|
||||
const NODES = [
|
||||
{ name: 'music-render', kind: 'render' },
|
||||
{ name: 'video-render', kind: 'render' },
|
||||
{ name: 'image-render', kind: 'render' },
|
||||
{ name: 'insta-render', kind: 'render' },
|
||||
{ name: 'task-watcher', kind: 'watcher' },
|
||||
{ name: 'ai_trade', kind: 'trader' },
|
||||
];
|
||||
|
||||
const hexToColor = (hex) => new THREE.Color(hex);
|
||||
|
||||
function workerByName(status, name) {
|
||||
if (!status || !Array.isArray(status.workers)) return null;
|
||||
return status.workers.find((w) => w.name === name) || null;
|
||||
}
|
||||
|
||||
// 링크의 현재 상태 문자열 → 'healthy'|'paused'|'degraded'|'down'|null
|
||||
function linkStatusOf(status, link) {
|
||||
if (!status) return null;
|
||||
if (link.kind === 'trunk') return status.redis_ok ? 'healthy' : 'down';
|
||||
const w = workerByName(status, link.worker);
|
||||
if (link.kind === 'branch' && !status.redis_ok) return 'down';
|
||||
if (!w) return 'down';
|
||||
return workerStatus(w);
|
||||
}
|
||||
|
||||
export default function PipelineScene({ status }) {
|
||||
const mountRef = useRef(null);
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return undefined;
|
||||
|
||||
let width = mount.clientWidth || 900;
|
||||
let height = mount.clientHeight || 520;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(52, width / height, 0.1, 200);
|
||||
camera.position.set(0, 1.4, 20.5);
|
||||
camera.lookAt(0, -0.3, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.domElement.style.display = 'block';
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
// ── lights ──
|
||||
scene.add(new THREE.AmbientLight(0x5577aa, 0.65));
|
||||
const l1 = new THREE.PointLight(0x00d4ff, 1.3, 80);
|
||||
l1.position.set(-10, 7, 14);
|
||||
scene.add(l1);
|
||||
const l2 = new THREE.PointLight(0x8b5cf6, 1.1, 80);
|
||||
l2.position.set(10, -7, 12);
|
||||
scene.add(l2);
|
||||
|
||||
// ── positions ──
|
||||
const nasPos = new THREE.Vector3(-9, 0, 0);
|
||||
const redisPos = new THREE.Vector3(-1.5, 0, 0);
|
||||
const colX = 8;
|
||||
const ys = [6.25, 3.75, 1.25, -1.25, -3.75, -6.25];
|
||||
const nodePositions = NODES.map((n, i) => new THREE.Vector3(colX, ys[i], 0));
|
||||
|
||||
const disposables = [];
|
||||
const track = (obj) => {
|
||||
if (obj.geometry) disposables.push(obj.geometry);
|
||||
if (obj.material) disposables.push(obj.material);
|
||||
return obj;
|
||||
};
|
||||
|
||||
// ── NAS node (left monolith) ──
|
||||
const nasMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2.2, 3.2, 1.4),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x0a2a44,
|
||||
emissiveIntensity: 0.9,
|
||||
metalness: 0.5,
|
||||
roughness: 0.35,
|
||||
})
|
||||
)
|
||||
);
|
||||
nasMesh.position.copy(nasPos);
|
||||
scene.add(nasMesh);
|
||||
|
||||
// ── Redis bus (vertical glowing spine) ──
|
||||
const busMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.55, 0.55, 13.2, 24, 1, true),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
)
|
||||
);
|
||||
busMesh.position.copy(redisPos);
|
||||
scene.add(busMesh);
|
||||
const busCore = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.18, 13.2, 16),
|
||||
new THREE.MeshBasicMaterial({ color: 0xe8f0fe, transparent: true, opacity: 0.9 })
|
||||
)
|
||||
);
|
||||
busCore.position.copy(redisPos);
|
||||
scene.add(busCore);
|
||||
|
||||
// ── worker nodes ──
|
||||
const nodeMeshes = NODES.map((n, i) => {
|
||||
const geo =
|
||||
n.kind === 'trader'
|
||||
? new THREE.IcosahedronGeometry(0.95, 0)
|
||||
: n.kind === 'watcher'
|
||||
? new THREE.OctahedronGeometry(1.0, 0)
|
||||
: new THREE.BoxGeometry(1.7, 1.4, 1.4);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x111a3a,
|
||||
emissiveIntensity: 1.0,
|
||||
metalness: 0.45,
|
||||
roughness: 0.4,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.copy(nodePositions[i]);
|
||||
scene.add(mesh);
|
||||
disposables.push(geo, mat);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
// ── links (curves) ──
|
||||
const particleGeo = new THREE.SphereGeometry(0.13, 8, 8);
|
||||
disposables.push(particleGeo);
|
||||
const PARTICLES_PER_LINK = 6;
|
||||
|
||||
function makeLink(curve, kind, worker) {
|
||||
const pts = curve.getPoints(60);
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x2a3a66,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
});
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
scene.add(line);
|
||||
disposables.push(lineGeo, lineMat);
|
||||
|
||||
const pMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
disposables.push(pMat);
|
||||
const particles = [];
|
||||
for (let k = 0; k < PARTICLES_PER_LINK; k += 1) {
|
||||
const pm = new THREE.Mesh(particleGeo, pMat);
|
||||
scene.add(pm);
|
||||
particles.push({ mesh: pm, t: k / PARTICLES_PER_LINK });
|
||||
}
|
||||
return { curve, kind, worker, line, lineMat, pMat, particles };
|
||||
}
|
||||
|
||||
const links = [];
|
||||
// trunk: NAS → Redis
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nasPos.clone().add(new THREE.Vector3(1.2, 0, 0)),
|
||||
new THREE.Vector3((nasPos.x + redisPos.x) / 2, 0.6, 1.2),
|
||||
redisPos.clone()
|
||||
),
|
||||
'trunk'
|
||||
)
|
||||
);
|
||||
// branches: Redis → render/watcher (indices 0..4)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const start = new THREE.Vector3(redisPos.x, ys[i] * 0.45, 0);
|
||||
const end = nodePositions[i].clone().add(new THREE.Vector3(-1.0, 0, 0));
|
||||
const ctrl = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, 1.6);
|
||||
links.push(makeLink(new THREE.QuadraticBezierCurve3(start, ctrl, end), 'branch', NODES[i].name));
|
||||
}
|
||||
// ai_trade: node → NAS directly (http-pull, bypasses Redis bus)
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nodePositions[5].clone().add(new THREE.Vector3(-0.9, -0.2, 0)),
|
||||
new THREE.Vector3(0, -9.5, 4.5),
|
||||
nasPos.clone().add(new THREE.Vector3(0.4, -1.4, 0))
|
||||
),
|
||||
'pull',
|
||||
'ai_trade'
|
||||
)
|
||||
);
|
||||
|
||||
// ── HTML label overlay ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'pipeline-labels';
|
||||
mount.appendChild(overlay);
|
||||
const makeLabel = (title, sub) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pipeline-label';
|
||||
el.innerHTML = `<span class="pl-dot"></span><span class="pl-name">${title}</span><span class="pl-state">${sub}</span>`;
|
||||
overlay.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
const nasLabel = makeLabel('NAS', '게이트웨이');
|
||||
nasLabel.classList.add('pipeline-label--anchor');
|
||||
const busLabel = makeLabel('Redis Bus', '큐');
|
||||
busLabel.classList.add('pipeline-label--anchor');
|
||||
const nodeLabels = NODES.map((n) => makeLabel(workerTitle(n.name), '—'));
|
||||
|
||||
const projectTo = (pos, el, dx = 0, dy = 0) => {
|
||||
const v = pos.clone().project(camera);
|
||||
const x = (v.x * 0.5 + 0.5) * width + dx;
|
||||
const y = (-v.y * 0.5 + 0.5) * height + dy;
|
||||
el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px)`;
|
||||
el.style.opacity = v.z < 1 ? '1' : '0';
|
||||
};
|
||||
|
||||
// ── animation ──
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const clock = { t: 0 };
|
||||
|
||||
const speedFor = (st) => {
|
||||
if (st === 'down' || st === 'paused' || st == null) return 0;
|
||||
return 0.16; // healthy/degraded base
|
||||
};
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
clock.t += dt;
|
||||
const status = statusRef.current;
|
||||
|
||||
// Redis bus color/pulse
|
||||
const redisOk = !status || status.redis_ok;
|
||||
const busColor = redisOk ? 0x00d4ff : 0xf43f5e;
|
||||
const pulse = 0.7 + Math.sin(clock.t * 2.2) * 0.18;
|
||||
busMesh.material.color.setHex(busColor);
|
||||
busMesh.material.opacity = 0.45 + pulse * 0.3;
|
||||
busCore.material.opacity = redisOk ? 0.55 + pulse * 0.35 : 0.5;
|
||||
|
||||
// per-link
|
||||
links.forEach((lk) => {
|
||||
const st = linkStatusOf(status, lk);
|
||||
const col = hexToColor(st ? linkColor(st) : '#2a3a66');
|
||||
lk.lineMat.color.copy(col);
|
||||
lk.lineMat.opacity = st === 'down' ? 0.5 : 0.55;
|
||||
lk.pMat.color.copy(col);
|
||||
|
||||
let speed = speedFor(st);
|
||||
// busy 워커는 빠르게
|
||||
if (lk.worker && status) {
|
||||
const w = workerByName(status, lk.worker);
|
||||
if (w && w.state === 'busy') speed = 0.42;
|
||||
}
|
||||
const showParticles = st !== 'down';
|
||||
lk.pMat.opacity = showParticles ? 0.95 : 0.0;
|
||||
lk.particles.forEach((p) => {
|
||||
p.t = (p.t + speed * dt) % 1;
|
||||
const pos = lk.curve.getPoint(p.t);
|
||||
p.mesh.position.copy(pos);
|
||||
p.mesh.visible = showParticles;
|
||||
const s = st === 'paused' ? 0.8 : 1 + Math.sin((p.t + clock.t) * 6) * 0.25;
|
||||
p.mesh.scale.setScalar(s);
|
||||
});
|
||||
});
|
||||
|
||||
// worker node color/pulse + labels
|
||||
NODES.forEach((n, i) => {
|
||||
const w = workerByName(status, n.name);
|
||||
const stt = workerStatus(w);
|
||||
const c = hexToColor(linkColor(stt));
|
||||
const mesh = nodeMeshes[i];
|
||||
mesh.material.emissive.copy(c);
|
||||
const alive = w && w.alive;
|
||||
const beat = alive ? 1.05 + Math.sin(clock.t * 3 + i) * 0.06 : 0.92;
|
||||
mesh.material.emissiveIntensity = alive ? 0.9 + Math.sin(clock.t * 3 + i) * 0.25 : 0.35;
|
||||
mesh.scale.setScalar(beat);
|
||||
mesh.rotation.y += dt * (n.kind === 'render' ? 0.15 : 0.4);
|
||||
|
||||
// label
|
||||
const el = nodeLabels[i];
|
||||
el.style.setProperty('--pl-color', linkColor(stt));
|
||||
const sub = el.querySelector('.pl-state');
|
||||
if (sub) sub.textContent = workerStateLabel(w);
|
||||
projectTo(nodePositions[i].clone().add(new THREE.Vector3(0, 1.5, 0)), el);
|
||||
});
|
||||
|
||||
// NAS / bus labels
|
||||
nasLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
projectTo(nasPos.clone().add(new THREE.Vector3(0, 2.2, 0)), nasLabel);
|
||||
busLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
const busSub = busLabel.querySelector('.pl-state');
|
||||
if (busSub) busSub.textContent = redisOk ? '정상' : '연결 끊김';
|
||||
projectTo(redisPos.clone().add(new THREE.Vector3(0, 7.3, 0)), busLabel);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
// ── resize ──
|
||||
const onResize = () => {
|
||||
width = mount.clientWidth || width;
|
||||
height = mount.clientHeight || height;
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(mount);
|
||||
|
||||
// ── cleanup ──
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
disposables.forEach((d) => d.dispose && d.dispose());
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} className="pipeline-canvas" />;
|
||||
}
|
||||
69
src/pages/infra/statusVisual.js
Normal file
69
src/pages/infra/statusVisual.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/pages/infra/statusVisual.js
|
||||
// 상태 → 색/라벨 매핑. 2D 패널과 Three.js 파이프라인이 공유하는 단일 진실원천.
|
||||
// 색은 index.css 테마 팔레트와 일치(neon-cyan healthy, amber paused, orange degraded, red down).
|
||||
|
||||
export const LINK_COLORS = {
|
||||
healthy: '#00d4ff', // neon-cyan — 통신이 흐름
|
||||
paused: '#fbbf24', // amber — 작업중(트레이딩) 일시정지
|
||||
degraded: '#fb923c', // orange — dead-letter 누적
|
||||
down: '#f43f5e', // red — 워커 다운/링크 끊김
|
||||
};
|
||||
|
||||
const NEUTRAL = '#4a5572';
|
||||
|
||||
export function linkColor(status) {
|
||||
return LINK_COLORS[status] || NEUTRAL;
|
||||
}
|
||||
|
||||
// 워커 객체 → 사람이 읽는 상태 라벨
|
||||
export function workerStateLabel(w) {
|
||||
if (!w || !w.alive) return '오프라인';
|
||||
switch (w.state) {
|
||||
case 'paused':
|
||||
return '일시정지';
|
||||
case 'busy':
|
||||
return '처리 중';
|
||||
case 'idle':
|
||||
return '대기';
|
||||
case 'market_open':
|
||||
return '장중';
|
||||
case 'market_closed':
|
||||
return '휴장';
|
||||
default:
|
||||
return '온라인';
|
||||
}
|
||||
}
|
||||
|
||||
// 워커 객체 → 링크 status 도출(2D/3D 공통). collect_status의 link 산정과 동일 규칙.
|
||||
export function workerStatus(w) {
|
||||
if (!w || !w.alive) return 'down';
|
||||
if (w.state === 'paused') return 'paused';
|
||||
if ((w.dead_letter || 0) > 0) return 'degraded';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function workerColor(w) {
|
||||
return linkColor(workerStatus(w));
|
||||
}
|
||||
|
||||
// 워커 내부명 → 표시 타이틀
|
||||
export const WORKER_TITLES = {
|
||||
'music-render': 'Music Render',
|
||||
'video-render': 'Video Render',
|
||||
'image-render': 'Image Render',
|
||||
'insta-render': 'Insta Render',
|
||||
'task-watcher': 'Task Watcher',
|
||||
ai_trade: 'AI Trade',
|
||||
};
|
||||
|
||||
export function workerTitle(name) {
|
||||
return WORKER_TITLES[name] || name;
|
||||
}
|
||||
|
||||
// kind → 한 줄 역할
|
||||
export function kindLabel(kind) {
|
||||
if (kind === 'render') return '렌더 워커';
|
||||
if (kind === 'watcher') return '작업 감시';
|
||||
if (kind === 'trader') return '트레이딩';
|
||||
return kind || '';
|
||||
}
|
||||
42
src/pages/infra/statusVisual.test.js
Normal file
42
src/pages/infra/statusVisual.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle } from './statusVisual';
|
||||
|
||||
describe('statusVisual', () => {
|
||||
it('maps link status to theme colors', () => {
|
||||
expect(linkColor('healthy')).toBe('#00d4ff');
|
||||
expect(linkColor('paused')).toBe('#fbbf24');
|
||||
expect(linkColor('degraded')).toBe('#fb923c');
|
||||
expect(linkColor('down')).toBe('#f43f5e');
|
||||
expect(linkColor('???')).toBe('#4a5572');
|
||||
});
|
||||
|
||||
it('labels a dead worker offline', () => {
|
||||
expect(workerStateLabel({ alive: false })).toBe('오프라인');
|
||||
expect(workerStateLabel(null)).toBe('오프라인');
|
||||
});
|
||||
|
||||
it('labels alive workers by state', () => {
|
||||
expect(workerStateLabel({ alive: true, state: 'idle' })).toBe('대기');
|
||||
expect(workerStateLabel({ alive: true, state: 'busy' })).toBe('처리 중');
|
||||
expect(workerStateLabel({ alive: true, state: 'paused' })).toBe('일시정지');
|
||||
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
|
||||
});
|
||||
|
||||
it('derives worker status with dead-letter and paused precedence', () => {
|
||||
expect(workerStatus({ alive: false })).toBe('down');
|
||||
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 3 })).toBe('degraded');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 0 })).toBe('healthy');
|
||||
});
|
||||
|
||||
it('workerColor follows workerStatus', () => {
|
||||
expect(workerColor({ alive: false })).toBe('#f43f5e');
|
||||
expect(workerColor({ alive: true, state: 'idle' })).toBe('#00d4ff');
|
||||
});
|
||||
|
||||
it('humanizes worker names', () => {
|
||||
expect(workerTitle('insta-render')).toBe('Insta Render');
|
||||
expect(workerTitle('ai_trade')).toBe('AI Trade');
|
||||
expect(workerTitle('unknown-x')).toBe('unknown-x');
|
||||
});
|
||||
});
|
||||
39
src/pages/infra/useNodeStatus.js
Normal file
39
src/pages/infra/useNodeStatus.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/pages/infra/useNodeStatus.js
|
||||
// /api/agent-office/nodes 를 주기 폴링하는 훅. 3초 권장(Three.js 흐름과 동기).
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
export function useNodeStatus(intervalMs = 4000) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatedAt, setUpdatedAt] = useState(null);
|
||||
const aliveRef = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
try {
|
||||
const d = await getNodeStatus();
|
||||
if (!aliveRef.current) return;
|
||||
setData(d);
|
||||
setError(null);
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (e) {
|
||||
if (!aliveRef.current) return;
|
||||
setError(e);
|
||||
} finally {
|
||||
if (aliveRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
tick();
|
||||
const id = setInterval(tick, intervalMs);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [tick, intervalMs]);
|
||||
|
||||
return { data, error, loading, updatedAt, refresh: tick };
|
||||
}
|
||||
26
src/pages/infra/useNodeStatus.test.js
Normal file
26
src/pages/infra/useNodeStatus.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
describe('useNodeStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetches node status on mount', async () => {
|
||||
getNodeStatus.mockResolvedValue({ redis_ok: true, workers: [], links: [] });
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.data).toBeTruthy());
|
||||
expect(result.current.data.redis_ok).toBe(true);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('captures fetch error', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.error.message).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@
|
||||
.ic-btn--danger { background: rgba(239,68,68,.15); color: #ef4444; }
|
||||
.ic-btn--danger:hover { background: rgba(239,68,68,.25); }
|
||||
.ic-btn--sm { padding: 4px 10px; font-size: 0.75rem; }
|
||||
a.ic-btn { color: inherit; }
|
||||
a.ic-btn:hover { color: inherit; }
|
||||
|
||||
.ic-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: ic-spin .6s linear infinite; display: inline-block; }
|
||||
@keyframes ic-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renderInstaSlate,
|
||||
deleteInstaSlate,
|
||||
getInstaAssetUrl,
|
||||
instaPackageUrl,
|
||||
getInstaTask,
|
||||
getInstaPrompt,
|
||||
putInstaPrompt,
|
||||
@@ -832,6 +833,9 @@ function SlateDetail({ slate, onDelete, onRender }) {
|
||||
</div>
|
||||
<div className="ic-detail__actions">
|
||||
<button className="ic-btn ic-btn--secondary ic-btn--sm" onClick={onRender}>재렌더</button>
|
||||
<a className="ic-btn ic-btn--secondary ic-btn--sm" href={instaPackageUrl(slate.id)} download>
|
||||
📦 패키지 다운로드 (10장 + 캡션)
|
||||
</a>
|
||||
<button className="ic-btn ic-btn--danger ic-btn--sm" onClick={onDelete}>삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
.winner-card .winner-meta strong { color: #f1f5f9; font-weight: 600; }
|
||||
.winner-card .winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
|
||||
|
||||
/* Backtest — WinnerAnalysisCard chart wrapper (standalone, not inside .winner-card) */
|
||||
.backtest-winner-chart { background: rgba(0,0,0,0.15); border-radius: 8px; padding: 8px; }
|
||||
|
||||
/* TrialsGrid */
|
||||
.trials-grid .grid {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr);
|
||||
@@ -186,6 +189,47 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Backtest — TrackRecordCard */
|
||||
.backtest-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.backtest-table th {
|
||||
text-align: left;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.backtest-table td {
|
||||
padding: 6px 8px;
|
||||
color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
.backtest-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* Backtest — shared note */
|
||||
.backtest-note {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backtest-note strong { color: #cbd5e1; }
|
||||
|
||||
/* Backtest — section divider */
|
||||
.backtest-section-header {
|
||||
margin: 8px 0 4px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trials-grid .grid { grid-template-columns: repeat(3, 1fr); height: auto; }
|
||||
.base-diff .diff-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
53
src/pages/lotto/evolver/CalibrationChart.jsx
Normal file
53
src/pages/lotto/evolver/CalibrationChart.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
export default function CalibrationChart({ history }) {
|
||||
if (!history || history.length === 0) {
|
||||
return (
|
||||
<div className="evolver-card backtest-calibration empty">
|
||||
<h2>당첨조합 캘리브레이션 추세</h2>
|
||||
<p className="muted">캘리브레이션 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// history는 DESC 순서로 오므로 역순해서 오름차순 x축
|
||||
const data = [...history].reverse().map((h) => ({
|
||||
draw: h.draw_no,
|
||||
score: h.score_total != null ? +h.score_total.toFixed(3) : null,
|
||||
pct: h.percentile != null ? +h.percentile.toFixed(3) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-calibration">
|
||||
<h2>당첨조합 캘리브레이션 추세 (최근 {history.length}회차)</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="draw" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<YAxis domain={[0, 1]} tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.1)', color: '#e2e8f0' }} />
|
||||
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: '0.8rem' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke="#f59e0b"
|
||||
dot={false}
|
||||
name="당첨조합 분석치"
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pct"
|
||||
stroke="#34d399"
|
||||
dot={false}
|
||||
name="무작위 percentile"
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/pages/lotto/evolver/TrackRecordCard.jsx
Normal file
56
src/pages/lotto/evolver/TrackRecordCard.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
const STRATEGY_ORDER = ['engine_w', 'random_null', 'coverage'];
|
||||
const STRATEGY_LABEL = { engine_w: '엔진', random_null: '무작위', coverage: '커버리지' };
|
||||
|
||||
export default function TrackRecordCard({ byStrategy }) {
|
||||
if (!byStrategy) return null;
|
||||
|
||||
const rows = STRATEGY_ORDER.filter((s) => byStrategy[s]);
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-track-record">
|
||||
<h2>누적 성적표</h2>
|
||||
{rows.length === 0 ? (
|
||||
<p className="backtest-note">아직 백테스트 데이터가 없습니다.</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="backtest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>전략</th>
|
||||
<th>누적 장수</th>
|
||||
<th>회차수</th>
|
||||
<th>1등</th>
|
||||
<th>2등</th>
|
||||
<th>3등</th>
|
||||
<th>4등</th>
|
||||
<th>5등</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => {
|
||||
const a = byStrategy[s];
|
||||
return (
|
||||
<tr key={s}>
|
||||
<td>{STRATEGY_LABEL[s] || s}</td>
|
||||
<td>{(a.n_tickets || 0).toLocaleString()}</td>
|
||||
<td>{a.draws || 0}</td>
|
||||
<td>{a['1st'] || 0}</td>
|
||||
<td>{a['2nd'] || 0}</td>
|
||||
<td>{a['3rd'] || 0}</td>
|
||||
<td>{a['4th'] || 0}</td>
|
||||
<td>{a['5th'] || 0}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="backtest-note">
|
||||
엔진이 무작위를 넘지 못하면 분석에 통계적 우위가 없다는 정직한 증거입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/pages/lotto/evolver/WinnerAnalysisCard.jsx
Normal file
49
src/pages/lotto/evolver/WinnerAnalysisCard.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
||||
Radar, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
export default function WinnerAnalysisCard({ analysis }) {
|
||||
if (!analysis) return null;
|
||||
|
||||
const data = [
|
||||
{ k: '빈도', v: analysis.score_frequency ?? 0 },
|
||||
{ k: '지문', v: analysis.score_fingerprint ?? 0 },
|
||||
{ k: '갭', v: analysis.score_gap ?? 0 },
|
||||
{ k: '공동출현', v: analysis.score_cooccur ?? 0 },
|
||||
{ k: '다양성', v: analysis.score_diversity ?? 0 },
|
||||
];
|
||||
|
||||
const pct = analysis.percentile != null
|
||||
? `${(analysis.percentile * 100).toFixed(0)}%`
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="evolver-card backtest-winner-analysis">
|
||||
<h2>
|
||||
이번 당첨조합 분석치
|
||||
<span className="badge">무작위 상위 {pct}</span>
|
||||
</h2>
|
||||
<div className="backtest-winner-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.12)" />
|
||||
<PolarAngleAxis dataKey="k" tick={{ fill: '#cbd5e1', fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 1]} tick={{ fill: '#64748b', fontSize: 10 }} />
|
||||
<Radar
|
||||
name="분석치"
|
||||
dataKey="v"
|
||||
stroke="#60a5fa"
|
||||
fill="#60a5fa"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="backtest-note">
|
||||
종합 점수: <strong>{(analysis.score_total ?? 0).toFixed(3)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../Evolver.css';
|
||||
import { useEvolverApi } from '../evolver/useEvolverApi';
|
||||
import WinnerCard from '../evolver/WinnerCard';
|
||||
@@ -7,10 +7,40 @@ import BaseDiff from '../evolver/BaseDiff';
|
||||
import BaseHistory from '../evolver/BaseHistory';
|
||||
import LottoActivityTimeline from '../evolver/LottoActivityTimeline';
|
||||
import EvolverActions from '../evolver/EvolverActions';
|
||||
import TrackRecordCard from '../evolver/TrackRecordCard';
|
||||
import CalibrationChart from '../evolver/CalibrationChart';
|
||||
import WinnerAnalysisCard from '../evolver/WinnerAnalysisCard';
|
||||
import { getLatest, lottoBacktestTrackRecord, lottoBacktestCalibration, lottoBacktestReview } from '../../../api';
|
||||
|
||||
export default function EvolverTab() {
|
||||
const { status, history, activity, loading, error, refetch } = useEvolverApi({ days: 7, weeks: 12 });
|
||||
|
||||
const [trackRecord, setTrackRecord] = useState(null);
|
||||
const [calibHistory, setCalibHistory] = useState([]);
|
||||
const [winnerAnalysis, setWinnerAnalysis] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [tr, cal] = await Promise.all([
|
||||
lottoBacktestTrackRecord(),
|
||||
lottoBacktestCalibration(52),
|
||||
]);
|
||||
setTrackRecord(tr);
|
||||
setCalibHistory(cal.history || []);
|
||||
} catch (_) { /* 백엔드 미준비 시 graceful skip */ }
|
||||
|
||||
try {
|
||||
const latest = await getLatest();
|
||||
const drawNo = latest?.drawNo || latest?.drw_no || latest?.draw_no;
|
||||
if (drawNo) {
|
||||
const review = await lottoBacktestReview(drawNo);
|
||||
setWinnerAnalysis(review.winner_analysis || null);
|
||||
}
|
||||
} catch (_) { /* 아직 데이터 없으면 null 유지 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="lotto-evolver"><p className="lotto-evolver-muted">로딩 중...</p></div>;
|
||||
if (error) return <div className="lotto-evolver"><p className="lotto-evolver-muted">에러: {String(error)}</p></div>;
|
||||
|
||||
@@ -73,6 +103,16 @@ export default function EvolverTab() {
|
||||
<EvolverActions onChange={refetch} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 백테스트 성적표 · 캘리브레이션 · 당첨조합 분석 */}
|
||||
{(winnerAnalysis || trackRecord || calibHistory.length > 0) && (
|
||||
<>
|
||||
<p className="backtest-section-header">백테스트 & 캘리브레이션</p>
|
||||
<WinnerAnalysisCard analysis={winnerAnalysis} />
|
||||
<TrackRecordCard byStrategy={trackRecord?.by_strategy} />
|
||||
<CalibrationChart history={calibHistory} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Saju.css';
|
||||
import SajuNav from './components/SajuNav';
|
||||
import HoryungMascot from './components/HoryungMascot';
|
||||
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 { compatInterpret } from '../../api';
|
||||
|
||||
const EMPTY_PERSON = {
|
||||
name: '', year: '', month: '', day: '', hour: null,
|
||||
gender: 'male', calendar_type: '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.year || !personA.month || !personA.day ||
|
||||
!personB.year || !personB.month || !personB.day) {
|
||||
setError('두 사람의 생년월일을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await compatInterpret({ person_a: personA, person_b: personB });
|
||||
navigate(`/saju/compatibility/result?cid=${res.reading_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-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<h2 className="saju-h2">궁합보기는 곧 만나요</h2>
|
||||
<p>두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.<br />조금만 기다려 주세요.</p>
|
||||
<Link to="/saju">메인으로 돌아가기</Link>
|
||||
</div>
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{mode === 'desktop' ? <MatchDesktop {...props} /> : <MatchMobile {...props} />}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,113 @@
|
||||
import React from 'react';
|
||||
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 MatchResultDesktop from './views/match-result.desktop.jsx';
|
||||
import { compatGetReading } 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;
|
||||
compatGetReading(parseInt(cid, 10))
|
||||
.then(setResult)
|
||||
.catch((e) => setError(e?.message || '결과를 가져오지 못했어요.'));
|
||||
}, [cid]);
|
||||
|
||||
const interp = result?.interpretation_json || {};
|
||||
const strengths = interp.strengths || [];
|
||||
const challenges = interp.challenges || [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', color: '#fff' }}>
|
||||
<h1>궁합 분석 결과</h1>
|
||||
<p>UI 시안 적용 대기 중...</p>
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{result && mode === 'desktop' ? (
|
||||
<MatchResultDesktop result={result} />
|
||||
) : (
|
||||
<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 || '사람 A'} × ${result.person_b?.name || '사람 B'}`} />
|
||||
<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>
|
||||
{interp.summary && (
|
||||
<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' }}>
|
||||
{interp.summary}
|
||||
</div>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
{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 }}>
|
||||
{strengths.map((s, i) => (<li key={i}>{s}</li>))}
|
||||
</ul>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
{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 }}>
|
||||
{challenges.map((s, i) => (<li key={i}>{s}</li>))}
|
||||
</ul>
|
||||
</OrnateFrame>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
81
src/pages/saju/Me.jsx
Normal file
81
src/pages/saju/Me.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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';
|
||||
import DesktopHero from './_shell/DesktopHero';
|
||||
import DesktopFooter from './_shell/DesktopFooter';
|
||||
import PrimaryButton from './_shell/PrimaryButton';
|
||||
import { IconPaw } from './_shell/Icons';
|
||||
|
||||
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 />}
|
||||
{mode === 'desktop' ? (
|
||||
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
|
||||
<DesktopHero
|
||||
title="상담안내"
|
||||
subtitle="필요할 때 언제든 1:1 상담으로 함께 합니다."
|
||||
accent="#1F2A44"
|
||||
bubble={<div>걱정 마세요!<br />저와 함께 차근차근<br />풀어가요.</div>}
|
||||
/>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '0 36px 32px' }}>
|
||||
<div className="k-frame" style={{ padding: '42px 48px', textAlign: 'center' }}>
|
||||
<div className="font-title" style={{ fontSize: 24, color: '#1F2A44', letterSpacing: '-0.03em' }}>
|
||||
전문가와 1:1 맞춤 상담
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 15, color: '#6B6B6B', lineHeight: 1.75 }}>
|
||||
사주풀이를 넘어, 인생의 큰 결정 앞에서 길잡이가 필요하실 때<br />
|
||||
검증된 명리학 전문가가 30분간 깊이 있게 풀어드립니다.
|
||||
</div>
|
||||
<PrimaryButton color="#1F2A44" full={false} style={{ margin: '24px auto 0', borderRadius: 999 }}>
|
||||
상담 신청하기 <IconPaw size={13} color="#E8C76B" />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
/* saju-page scope — 다른 페이지에 영향 없음 */
|
||||
.saju-page {
|
||||
/* 베이스 */
|
||||
--saju-cream: #FAF6EE;
|
||||
--saju-paper: #F2EAD8;
|
||||
--saju-ink: #2E2D45;
|
||||
--saju-ink-deep: #1F1D38;
|
||||
|
||||
/* 액센트 */
|
||||
--saju-gold: #D4A574;
|
||||
--saju-gold-deep: #B5874E;
|
||||
--saju-apricot: #C58F76;
|
||||
--saju-rose: #D9A2A6;
|
||||
--saju-jade: #4B7065;
|
||||
--saju-violet: #6A5285;
|
||||
|
||||
/* 카테고리 (3 ActionCard) */
|
||||
--saju-today-bg: #4B7065;
|
||||
--saju-gunghab-bg: #A8736E;
|
||||
--saju-saju-bg: #4F4A78;
|
||||
|
||||
/* 점수 카테고리 (4 ScoreCard) */
|
||||
--saju-wealth: #D4A574;
|
||||
--saju-romance: #D9A2A6;
|
||||
--saju-social: #4B7065;
|
||||
--saju-career: #6A5285;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--saju-cream);
|
||||
color: var(--saju-ink);
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.saju-page * { box-sizing: border-box; }
|
||||
|
||||
.saju-page .saju-h1,
|
||||
.saju-page .saju-h2,
|
||||
.saju-page .saju-h3 {
|
||||
font-family: 'Noto Serif KR', 'Pretendard', serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--saju-ink);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.saju-page .saju-h1 { font-size: clamp(2.5rem, 4vw, 3.5rem); line-height: 1.2; }
|
||||
.saju-page .saju-h2 { font-size: clamp(1.8rem, 3vw, 2.5rem); line-height: 1.3; }
|
||||
.saju-page .saju-h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); }
|
||||
|
||||
/* 호령 마스코트 */
|
||||
.horyung-mascot { display: block; object-fit: contain; }
|
||||
.horyung-mascot--sm { width: 80px; height: auto; }
|
||||
.horyung-mascot--md { width: 180px; height: auto; }
|
||||
.horyung-mascot--lg { width: 320px; height: auto; }
|
||||
|
||||
/* 상단 네비게이션 */
|
||||
.saju-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--saju-ink);
|
||||
color: var(--saju-cream);
|
||||
}
|
||||
.saju-nav__logo {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--saju-cream);
|
||||
text-decoration: none;
|
||||
}
|
||||
.saju-nav__links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.saju-nav__links a {
|
||||
color: var(--saju-cream);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.saju-nav__links a:hover { opacity: 1; }
|
||||
.saju-nav__cta {
|
||||
background: var(--saju-gold);
|
||||
color: var(--saju-ink);
|
||||
border: none;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.saju-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 3rem;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.saju-hero__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.saju-quote-box {
|
||||
background: var(--saju-paper);
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--saju-gold-deep);
|
||||
color: var(--saju-ink);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
max-width: 280px;
|
||||
}
|
||||
.saju-hero__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.saju-sub {
|
||||
color: var(--saju-ink);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ActionCard */
|
||||
.saju-action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.saju-action-card {
|
||||
background: var(--saju-saju-bg);
|
||||
color: var(--saju-cream);
|
||||
padding: 1.5rem 1rem;
|
||||
border-radius: 16px;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
}
|
||||
.saju-action-card:hover { transform: translateY(-4px); }
|
||||
.saju-action-card--today { background: var(--saju-today-bg); }
|
||||
.saju-action-card--gunghab { background: var(--saju-gunghab-bg); }
|
||||
.saju-action-card--saju { background: var(--saju-saju-bg); }
|
||||
.saju-action-card[aria-disabled="true"] { opacity: 0.6; cursor: not-allowed; }
|
||||
.saju-action-card__icon { font-size: 2rem; }
|
||||
.saju-action-card__title { font-size: 1.1rem; font-weight: 700; }
|
||||
.saju-action-card__desc { font-size: 0.85rem; opacity: 0.85; text-align: center; }
|
||||
|
||||
/* Bottom */
|
||||
.saju-bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: var(--saju-ink);
|
||||
color: var(--saju-cream);
|
||||
border-radius: 24px 24px 0 0;
|
||||
}
|
||||
.saju-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.saju-form input,
|
||||
.saju-form select {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--saju-gold-deep);
|
||||
background: var(--saju-ink-deep);
|
||||
color: var(--saju-cream);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.saju-form button {
|
||||
background: var(--saju-gold);
|
||||
color: var(--saju-ink);
|
||||
border: none;
|
||||
padding: 0.875rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.saju-form button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.saju-form__error {
|
||||
background: rgba(217, 162, 166, 0.2);
|
||||
color: var(--saju-rose);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Fortune ring */
|
||||
.saju-fortune-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.saju-fortune-ring svg { width: 200px; height: 200px; }
|
||||
.saju-fortune-ring__score {
|
||||
position: absolute;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--saju-ink);
|
||||
}
|
||||
.saju-fortune-ring__total { font-size: 0.9rem; color: var(--saju-ink); opacity: 0.6; }
|
||||
|
||||
/* ScoreCard */
|
||||
.saju-score-card {
|
||||
background: var(--saju-cream);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--saju-paper);
|
||||
}
|
||||
.saju-score-card__head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.saju-score-card__icon { font-size: 1.5rem; }
|
||||
.saju-score-card__title { font-weight: 700; font-size: 0.95rem; }
|
||||
.saju-score-card__value {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--saju-ink);
|
||||
}
|
||||
.saju-score-card__bar {
|
||||
height: 6px;
|
||||
background: var(--saju-paper);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.saju-score-card__bar > div { height: 100%; background: var(--saju-gold); transition: width 0.5s; }
|
||||
|
||||
/* Lucky box */
|
||||
.saju-lucky-box {
|
||||
background: var(--saju-paper);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.saju-lucky-box__item { text-align: center; }
|
||||
.saju-lucky-box__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.7; margin-bottom: 0.25rem; }
|
||||
.saju-lucky-box__value {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--saju-ink);
|
||||
}
|
||||
|
||||
/* SajuPillars */
|
||||
.saju-pillars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.saju-pillar {
|
||||
background: var(--saju-paper);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.saju-pillar__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.6; margin-bottom: 0.5rem; }
|
||||
.saju-pillar__stem,
|
||||
.saju-pillar__branch {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
.saju-pillar__stem-kr,
|
||||
.saju-pillar__branch-kr { font-size: 0.85rem; opacity: 0.7; }
|
||||
.saju-pillar__ten-god,
|
||||
.saju-pillar__fortune { font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.7; }
|
||||
|
||||
/* Element bars */
|
||||
.saju-element-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--saju-cream);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.saju-element-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 50px;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.saju-element-bar__label { font-size: 0.9rem; font-weight: 700; }
|
||||
.saju-element-bar__track {
|
||||
height: 12px;
|
||||
background: var(--saju-paper);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.saju-element-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
.saju-element-bar__fill--木 { background: #4B7065; }
|
||||
.saju-element-bar__fill--火 { background: #C56F5C; }
|
||||
.saju-element-bar__fill--土 { background: #D4A574; }
|
||||
.saju-element-bar__fill--金 { background: #B8B5A8; }
|
||||
.saju-element-bar__fill--水 { background: #4A5878; }
|
||||
.saju-element-bar__value { text-align: right; font-size: 0.85rem; opacity: 0.7; }
|
||||
|
||||
/* Monthly flow */
|
||||
.saju-monthly-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
background: var(--saju-cream);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.saju-monthly-flow__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 8px;
|
||||
background: var(--saju-paper);
|
||||
}
|
||||
.saju-monthly-flow__month { font-size: 0.7rem; opacity: 0.7; }
|
||||
.saju-monthly-flow__score {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.saju-monthly-flow__label { font-size: 0.7rem; opacity: 0.8; margin-top: 0.25rem; }
|
||||
|
||||
/* Horyung quote */
|
||||
.saju-horyung-quote {
|
||||
background: var(--saju-ink);
|
||||
color: var(--saju-cream);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.saju-horyung-quote__text { font-size: 0.95rem; line-height: 1.6; }
|
||||
|
||||
/* Interpret accordion */
|
||||
.saju-interpret-accordion { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.saju-interpret-item {
|
||||
background: var(--saju-cream);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--saju-paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
.saju-interpret-item__header {
|
||||
padding: 1rem;
|
||||
background: var(--saju-paper);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
.saju-interpret-item__body { padding: 1rem; font-size: 0.95rem; line-height: 1.6; }
|
||||
.saju-interpret-item__evidence {
|
||||
background: var(--saju-paper);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Stub */
|
||||
.saju-stub {
|
||||
max-width: 480px;
|
||||
margin: 5rem auto;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--saju-paper);
|
||||
border-radius: 24px;
|
||||
}
|
||||
.saju-stub a {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
background: var(--saju-gold);
|
||||
color: var(--saju-ink);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1280px) {
|
||||
.saju-hero { grid-template-columns: 1fr; text-align: center; }
|
||||
.saju-hero__left { order: 2; }
|
||||
.saju-hero__right { order: 1; }
|
||||
.saju-bottom { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.saju-nav { padding: 0.75rem 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.saju-nav__links { display: none; }
|
||||
.saju-action-cards { grid-template-columns: 1fr; }
|
||||
.saju-pillars { grid-template-columns: repeat(2, 1fr); }
|
||||
.saju-monthly-flow { grid-template-columns: repeat(4, 1fr); }
|
||||
.horyung-mascot--lg { width: 220px; }
|
||||
}
|
||||
@@ -1,80 +1,19 @@
|
||||
import React from 'react';
|
||||
import './Saju.css';
|
||||
import SajuNav from './components/SajuNav';
|
||||
import HoryungMascot from './components/HoryungMascot';
|
||||
import SajuInputForm from './components/SajuInputForm';
|
||||
import ActionCard from './components/ActionCard';
|
||||
import useSajuForm from './hooks/useSajuForm';
|
||||
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 { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||||
|
||||
const mode = useViewportMode();
|
||||
return (
|
||||
<div className="saju-page saju-page--main">
|
||||
<SajuNav />
|
||||
|
||||
<section className="saju-hero">
|
||||
<div className="saju-hero__left">
|
||||
<HoryungMascot pose="greeting" size="lg" />
|
||||
<div className="saju-quote-box">
|
||||
<p style={{ margin: 0 }}>
|
||||
전통 사주명리학 + AI 인사이트로<br />
|
||||
당신의 오늘을 풀어드립니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="saju-hero__right">
|
||||
<h1 className="saju-h1">호령이 반갑게<br />맞이하는 오늘의 사주</h1>
|
||||
<p className="saju-sub">
|
||||
오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.<br />
|
||||
당신의 사주 8자에 담긴 운명을 만나보세요.
|
||||
</p>
|
||||
|
||||
<div className="saju-action-cards">
|
||||
<ActionCard
|
||||
to="/saju/today"
|
||||
icon="today"
|
||||
title="오늘의 운세"
|
||||
desc="오늘 하루의 흐름을 확인하세요"
|
||||
variant="today"
|
||||
/>
|
||||
<ActionCard
|
||||
to="/saju/compatibility"
|
||||
icon="heart"
|
||||
title="궁합보기"
|
||||
desc="두 사람의 인연을 풀어보세요"
|
||||
variant="gunghab"
|
||||
disabled
|
||||
/>
|
||||
<ActionCard
|
||||
to="/saju/result"
|
||||
icon="book"
|
||||
title="사주풀이"
|
||||
desc="당신의 사주 8자를 자세히"
|
||||
variant="saju"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="saju-bottom">
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ color: 'var(--saju-cream)' }}>
|
||||
오늘의 운세 미리보기
|
||||
</h2>
|
||||
<p style={{ color: 'var(--saju-cream)', opacity: 0.7, lineHeight: 1.6 }}>
|
||||
사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를 한 번에<br />
|
||||
확인하실 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<SajuInputForm
|
||||
form={form}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</section>
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' ? <DesktopHeader /> : null}
|
||||
{mode === 'desktop' ? <HomeDesktop /> : <HomeMobile />}
|
||||
{mode === 'mobile' ? <BottomNav theme="navy" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import './Saju.css';
|
||||
import SajuNav from './components/SajuNav';
|
||||
import HoryungMascot from './components/HoryungMascot';
|
||||
import SajuPillars from './components/SajuPillars';
|
||||
import ElementBarChart from './components/ElementBarChart';
|
||||
import InterpretAccordion from './components/InterpretAccordion';
|
||||
import HoryungQuote from './components/HoryungQuote';
|
||||
import MonthlyFlow from './components/MonthlyFlow';
|
||||
import { useSearchParams } 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 GhostButton from './_shell/GhostButton';
|
||||
import SajuMobile from './views/saju.mobile.jsx';
|
||||
import SajuDesktop from './views/saju.desktop.jsx';
|
||||
import sampleReading from './sampleReading';
|
||||
|
||||
export default function SajuResult() {
|
||||
const mode = useViewportMode();
|
||||
const [params] = useSearchParams();
|
||||
const rid = params.get('rid');
|
||||
const ridNum = rid ? parseInt(rid, 10) : null;
|
||||
const { data, loading, error } = useSajuReading(ridNum);
|
||||
|
||||
if (!rid) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<h2 className="saju-h2">사주 정보가 없어요</h2>
|
||||
<p>먼저 메인 페이지에서 사주를 입력해주세요.</p>
|
||||
<Link to="/saju">메인으로 가기</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<p>호령이 사주를 풀어보는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<h2 className="saju-h2">사주 결과를 찾을 수 없어요</h2>
|
||||
<p>{error || '다시 입력해주세요.'}</p>
|
||||
<Link to="/saju">메인으로 가기</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const saju = data.saju_data;
|
||||
const analysis = data.analysis_data;
|
||||
const interp = data.interpretation_json;
|
||||
const monthlyFlow = data.monthly_flow;
|
||||
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
|
||||
<section className="saju-hero">
|
||||
<div className="saju-hero__left">
|
||||
<HoryungMascot pose="thinking" size="lg" />
|
||||
</div>
|
||||
<div className="saju-hero__right">
|
||||
<h1 className="saju-h1">사주풀이</h1>
|
||||
<p className="saju-sub">
|
||||
{data.birth_year}년 {data.birth_month}월 {data.birth_day}일
|
||||
{data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '}
|
||||
{data.gender === 'male' ? '남' : '여'} ·{' '}
|
||||
{data.calendar_type === 'lunar' ? '음력' : '양력'}
|
||||
</p>
|
||||
{interp?.summary && (
|
||||
<HoryungQuote pose="thinking" text={interp.summary} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>사주 4기둥</h2>
|
||||
<SajuPillars saju={saju} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오행 분석</h2>
|
||||
<ElementBarChart scores={analysis?.element_scores} />
|
||||
</div>
|
||||
|
||||
{analysis?.day_master_strength && (
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>일간 강도</h2>
|
||||
<div className="saju-quote-box" style={{ maxWidth: 'none' }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>{analysis.day_master_strength.result}</strong> · 점수 {analysis.day_master_strength.score}<br />
|
||||
{(analysis.day_master_strength.reasons || []).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthlyFlow && (
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>12개월 운세 흐름</h2>
|
||||
<MonthlyFlow flow={monthlyFlow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{interp?.items && (
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>AI 12항목 해석</h2>
|
||||
<InterpretAccordion items={interp.items} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{interp?.advice && (
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>호령의 조언</h2>
|
||||
<HoryungQuote pose="happy" text={interp.advice} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
||||
<Link to={`/saju/today?rid=${rid}`} className="saju-action-card saju-action-card--today" style={{ maxWidth: 240 }}>
|
||||
<span className="saju-action-card__icon">☀</span>
|
||||
<span className="saju-action-card__title">오늘의 운세</span>
|
||||
</Link>
|
||||
<Link to="/saju" className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
||||
<span className="saju-action-card__icon">📖</span>
|
||||
<span className="saju-action-card__title">새 사주 보기</span>
|
||||
</Link>
|
||||
</section>
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{!rid && (mode === 'desktop'
|
||||
? <SajuDesktop reading={sampleReading} />
|
||||
: <SajuMobile reading={sampleReading} />
|
||||
)}
|
||||
{rid && loading && <LoadingState />}
|
||||
{rid && error && <ErrorState />}
|
||||
{rid && data && (mode === 'desktop'
|
||||
? <SajuDesktop reading={data} />
|
||||
: <SajuMobile reading={data} />
|
||||
)}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
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={() => window.location.reload()}>다시 시도</GhostButton>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,138 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import './Saju.css';
|
||||
import SajuNav from './components/SajuNav';
|
||||
import HoryungMascot from './components/HoryungMascot';
|
||||
import FortuneRing from './components/FortuneRing';
|
||||
import ScoreCard from './components/ScoreCard';
|
||||
import LuckyBox from './components/LuckyBox';
|
||||
import HoryungQuote from './components/HoryungQuote';
|
||||
import { useSearchParams } 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 GhostButton from './_shell/GhostButton';
|
||||
import TodayMobile from './views/today.mobile.jsx';
|
||||
import TodayDesktop from './views/today.desktop.jsx';
|
||||
import sampleReading from './sampleReading';
|
||||
|
||||
export default function Today() {
|
||||
const mode = useViewportMode();
|
||||
const [params] = useSearchParams();
|
||||
const rid = params.get('rid');
|
||||
const ridNum = rid ? parseInt(rid, 10) : null;
|
||||
const { data, loading, error } = useSajuReading(ridNum);
|
||||
|
||||
if (!rid) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<h2 className="saju-h2">사주가 필요해요</h2>
|
||||
<p>오늘의 운세를 보려면 먼저 사주를 입력해주세요.</p>
|
||||
<Link to="/saju">사주 입력하러 가기</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<HoryungMascot pose="thinking" />
|
||||
<p>오늘의 운세를 풀어보는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
<div className="saju-stub">
|
||||
<h2 className="saju-h2">결과를 찾을 수 없어요</h2>
|
||||
<p>{error || '다시 입력해주세요.'}</p>
|
||||
<Link to="/saju">메인으로 가기</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scores = data.fortune_scores;
|
||||
const lucky = data.lucky;
|
||||
|
||||
return (
|
||||
<div className="saju-page">
|
||||
<SajuNav />
|
||||
|
||||
<section className="saju-hero">
|
||||
<div className="saju-hero__left">
|
||||
<HoryungMascot pose="pointing" size="lg" />
|
||||
</div>
|
||||
<div className="saju-hero__right">
|
||||
<h1 className="saju-h1">오늘의 운세</h1>
|
||||
<p className="saju-sub">
|
||||
오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
||||
{scores && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 종합점</h2>
|
||||
<FortuneRing score={scores.overall} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
|
||||
<ScoreCard category="wealth" score={scores.wealth} />
|
||||
<ScoreCard category="romance" score={scores.romance} />
|
||||
<ScoreCard category="social" score={scores.social} />
|
||||
<ScoreCard category="career" score={scores.career} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lucky && (
|
||||
<div>
|
||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 럭키</h2>
|
||||
<LuckyBox lucky={lucky} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lucky?.good_signs?.length || lucky?.warnings?.length) ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{lucky.good_signs?.length > 0 && (
|
||||
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(75, 112, 101, 0.15)' }}>
|
||||
<strong style={{ color: 'var(--saju-jade)' }}>✦ 행운 알림</strong>
|
||||
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
||||
{lucky.good_signs.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{lucky.warnings?.length > 0 && (
|
||||
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(197, 143, 118, 0.15)' }}>
|
||||
<strong style={{ color: 'var(--saju-apricot)' }}>⚠ 주의사항</strong>
|
||||
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
||||
{lucky.warnings.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HoryungQuote
|
||||
pose="happy"
|
||||
text="오늘 하루도 호령과 함께 평안하시길 바라요. 작은 신호에도 귀 기울이세요."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
||||
<Link to={`/saju/result?rid=${rid}`} className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
||||
<span className="saju-action-card__icon">📖</span>
|
||||
<span className="saju-action-card__title">사주풀이 보기</span>
|
||||
</Link>
|
||||
<Link to="/saju" className="saju-action-card saju-action-card--gunghab" style={{ maxWidth: 240 }} aria-disabled="true">
|
||||
<span className="saju-action-card__icon">♥</span>
|
||||
<span className="saju-action-card__title">궁합 (준비 중)</span>
|
||||
</Link>
|
||||
</section>
|
||||
<div className="saju-v2">
|
||||
{mode === 'desktop' && <DesktopHeader />}
|
||||
{!rid && (mode === 'desktop'
|
||||
? <TodayDesktop reading={sampleReading} />
|
||||
: <TodayMobile reading={sampleReading} />
|
||||
)}
|
||||
{rid && loading && <LoadingState />}
|
||||
{rid && error && <ErrorState />}
|
||||
{rid && data && (mode === 'desktop'
|
||||
? <TodayDesktop reading={data} />
|
||||
: <TodayMobile reading={data} />
|
||||
)}
|
||||
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
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={() => window.location.reload()}>다시 시도</GhostButton>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
77
src/pages/saju/_shell/BottomNav.jsx
Normal file
77
src/pages/saju/_shell/BottomNav.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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 };
|
||||
13
src/pages/saju/_shell/BrandMark.jsx
Normal file
13
src/pages/saju/_shell/BrandMark.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function BrandMark({ size = 36 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||
<circle cx="20" cy="20" r="18" stroke="#B89530" strokeWidth="1.2" />
|
||||
<circle cx="20" cy="20" r="14" stroke="#D4AF37" strokeWidth="1" opacity="0.7" />
|
||||
<path d="M20 5v30M5 20h30M9 9l22 22M31 9 9 31" stroke="#D4AF37" strokeWidth="0.7" opacity="0.4" />
|
||||
<circle cx="20" cy="20" r="4" fill="#D4AF37" />
|
||||
<circle cx="20" cy="20" r="1.8" fill="#FBF7EF" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
50
src/pages/saju/_shell/DesktopFooter.jsx
Normal file
50
src/pages/saju/_shell/DesktopFooter.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { IconSparkle, IconSun, IconUser } from './Icons';
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#D4AF37" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FOOTER_ITEMS = [
|
||||
{ label: '전통 명리학 기반', desc: '깊이 있는 전통 해석', icon: <IconSun size={22} stroke="#D4AF37" /> },
|
||||
{ label: 'AI 맞춤 인사이트', desc: '데이터 기반 정확도 향상', icon: <IconSparkle size={20} color="#D4AF37" /> },
|
||||
{ label: '1:1 상담 연계', desc: '필요시 전문가 상담 연결', icon: <IconUser size={22} stroke="#D4AF37" /> },
|
||||
{ label: '안전한 개인정보 관리', desc: '철저한 보안과 비식별 처리', icon: <ShieldIcon /> },
|
||||
];
|
||||
|
||||
export default function DesktopFooter() {
|
||||
return (
|
||||
<footer style={{
|
||||
marginTop: 48,
|
||||
borderTop: '1px solid rgba(31,42,68,0.08)',
|
||||
background: 'rgba(247,242,232,0.6)',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1400, margin: '0 auto', padding: '24px 36px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24,
|
||||
}}>
|
||||
{FOOTER_ITEMS.map((item) => (
|
||||
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(212,175,55,0.10)', border: '1px solid rgba(212,175,55,0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>{item.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44', letterSpacing: '-0.02em' }}>{item.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#6B6B6B', marginTop: 2, letterSpacing: '-0.01em' }}>{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '14px 0 28px', fontSize: 11, color: '#9A968D', letterSpacing: '0.04em' }}>
|
||||
© 2026 호령사주 · BAEKHO SAJU DOSA
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
94
src/pages/saju/_shell/DesktopHeader.jsx
Normal file
94
src/pages/saju/_shell/DesktopHeader.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import BrandMark from './BrandMark';
|
||||
import { IconChevron } from './Icons';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'today', to: '/saju/today', label: '오늘의 운세' },
|
||||
{ id: 'match', to: '/saju/compatibility', label: '궁합보기' },
|
||||
{ id: 'saju', to: '/saju/result', label: '사주풀이' },
|
||||
{ id: 'me', to: '/saju/me', label: '상담안내' },
|
||||
];
|
||||
|
||||
function pathToCurrent(pathname) {
|
||||
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: 10, zIndex: 40,
|
||||
width: 'calc(100% - 72px)', maxWidth: 1368, height: 68,
|
||||
margin: '10px auto 0',
|
||||
background: 'rgba(251,247,239,0.88)',
|
||||
border: '1px solid rgba(31,42,68,0.13)',
|
||||
borderRadius: 999,
|
||||
display: 'flex', alignItems: 'center', padding: '0 22px 0 28px',
|
||||
backdropFilter: 'blur(18px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(18px) saturate(150%)',
|
||||
boxShadow: '0 8px 24px rgba(31,42,68,0.06), inset 0 1px 0 rgba(255,255,255,0.75)',
|
||||
}}>
|
||||
<button onClick={() => navigate('/saju')} style={{
|
||||
background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: 0, minWidth: 220,
|
||||
}}>
|
||||
<BrandMark size={40} />
|
||||
<span className="font-title" style={{
|
||||
fontSize: 26, color: '#1F2A44', letterSpacing: '-0.03em', lineHeight: 1,
|
||||
}}>호령사주</span>
|
||||
</button>
|
||||
|
||||
<nav aria-label="사주 메뉴" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 24, flex: 1,
|
||||
}}>
|
||||
{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.07)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: active ? 18 : 0,
|
||||
padding: active ? '11px 24px' : '11px 10px',
|
||||
color: active ? '#1F2A44' : '#202638',
|
||||
fontSize: 16,
|
||||
fontWeight: active ? 800 : 700,
|
||||
letterSpacing: '-0.03em',
|
||||
}}>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button onClick={() => navigate('/saju')} style={{
|
||||
padding: '13px 22px 13px 26px',
|
||||
borderRadius: 999,
|
||||
background: '#1F2A44',
|
||||
color: '#F7F2E8',
|
||||
border: '1px solid rgba(212,175,55,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.02em',
|
||||
boxShadow: '0 6px 18px rgba(31,42,68,0.18), inset 0 1px 0 rgba(212,175,55,0.3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
사주풀이 시작하기
|
||||
<IconChevron dir="right" size={14} color="#E8C76B" />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
68
src/pages/saju/_shell/DesktopHero.jsx
Normal file
68
src/pages/saju/_shell/DesktopHero.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Mascot from './Mascot';
|
||||
import OrnamentBloom from './OrnamentBloom';
|
||||
import { IconPaw } from './Icons';
|
||||
|
||||
export default function DesktopHero({
|
||||
title,
|
||||
subtitle,
|
||||
accent = '#D4AF37',
|
||||
bubble,
|
||||
mascotVariant = 'full',
|
||||
}) {
|
||||
return (
|
||||
<section className="mt-wash" style={{ position: 'relative', padding: '56px 36px 64px', overflow: 'hidden' }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="bloom-row" style={{ color: accent, display: 'inline-flex', alignItems: 'center', gap: 12 }}>
|
||||
<svg width="60" height="6" viewBox="0 0 60 6">
|
||||
<path d="M0 3 L56 3" stroke={accent} strokeWidth="1" />
|
||||
<circle cx="58" cy="3" r="2" fill={accent} />
|
||||
</svg>
|
||||
<OrnamentBloom size={22} color={accent} />
|
||||
<svg width="60" height="6" viewBox="0 0 60 6">
|
||||
<circle cx="2" cy="3" r="2" fill={accent} />
|
||||
<path d="M4 3 L60 3" stroke={accent} strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-title" style={{
|
||||
margin: '14px 0 0', fontSize: 72, color: '#1F2A44',
|
||||
letterSpacing: '-0.04em', lineHeight: 1,
|
||||
}}>{title}</h1>
|
||||
<div style={{
|
||||
marginTop: 18, fontSize: 16, color: '#6B6B6B',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>{subtitle}</div>
|
||||
</div>
|
||||
|
||||
{bubble && (
|
||||
<div style={{ position: 'absolute', right: 220, top: 36, maxWidth: 210 }}>
|
||||
<div style={{
|
||||
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.12)',
|
||||
borderRadius: 18, padding: '15px 17px',
|
||||
fontSize: 13, color: '#1F2A44', lineHeight: 1.65, letterSpacing: '-0.01em',
|
||||
boxShadow: '0 4px 14px rgba(31,42,68,0.08)', position: 'relative',
|
||||
}}>
|
||||
{bubble}
|
||||
<div style={{
|
||||
position: 'absolute', right: -7, bottom: 18,
|
||||
width: 14, height: 14, background: '#FBF7EF',
|
||||
borderRight: '1px solid rgba(31,42,68,0.12)',
|
||||
borderBottom: '1px solid rgba(31,42,68,0.12)',
|
||||
transform: 'rotate(-45deg)',
|
||||
}} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4, color: '#B89530', opacity: 0.7 }}>
|
||||
<IconPaw size={11} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Mascot variant={mascotVariant} size={220} style={{
|
||||
position: 'absolute', right: -10, top: -8, width: 220, pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 8px 24px rgba(31,42,68,0.18))',
|
||||
}} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
18
src/pages/saju/_shell/GhostButton.jsx
Normal file
18
src/pages/saju/_shell/GhostButton.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
119
src/pages/saju/_shell/Icons.jsx
Normal file
119
src/pages/saju/_shell/Icons.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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 IconStar({ size = 16, filled = true, color = '#D4AF37' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24"
|
||||
fill={filled ? color : 'none'} stroke={color} strokeWidth="1.4"
|
||||
strokeLinejoin="round">
|
||||
<path d="M12 3l2.7 5.7 6.3.9-4.6 4.4 1.1 6.2L12 17.3 6.5 20.2l1.1-6.2L3 9.6l6.3-.9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMoney({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||
<path d="M9 5c-2 0-3 1.5-3 3l6 2c2 .7 3 1.5 3 3 0 1.8-1.5 3-4 3" />
|
||||
<path d="M12 4v2M12 18v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCalendar({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||
<rect x="4" y="6" width="16" height="14" rx="2" />
|
||||
<path d="M4 10h16M9 4v4M15 4v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClock({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
32
src/pages/saju/_shell/InputRow.jsx
Normal file
32
src/pages/saju/_shell/InputRow.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
src/pages/saju/_shell/Mascot.jsx
Normal file
18
src/pages/saju/_shell/Mascot.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const VARIANT_TO_SRC = {
|
||||
full: '/images/saju/horyung/horyung-main.png',
|
||||
head: '/images/saju/horyung/horyung-head.png',
|
||||
upper: '/images/saju/horyung/horyung-upper.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 }} />
|
||||
);
|
||||
}
|
||||
16
src/pages/saju/_shell/Mascot.test.jsx
Normal file
16
src/pages/saju/_shell/Mascot.test.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/pages/saju/_shell/MascotBubble.jsx
Normal file
40
src/pages/saju/_shell/MascotBubble.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
src/pages/saju/_shell/OrnamentBloom.jsx
Normal file
13
src/pages/saju/_shell/OrnamentBloom.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
36
src/pages/saju/_shell/OrnateFrame.jsx
Normal file
36
src/pages/saju/_shell/OrnateFrame.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
21
src/pages/saju/_shell/PanelHeader.jsx
Normal file
21
src/pages/saju/_shell/PanelHeader.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import OrnamentBloom from './OrnamentBloom';
|
||||
|
||||
export default function PanelHeader({
|
||||
title,
|
||||
color = '#1F2A44',
|
||||
accent = '#D4AF37',
|
||||
right = null,
|
||||
icon = null,
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
{icon || <OrnamentBloom size={20} color={accent} />}
|
||||
<h3 className="font-title" style={{
|
||||
margin: 0, fontSize: 18, color, letterSpacing: '-0.02em',
|
||||
}}>{title}</h3>
|
||||
<div style={{ flex: 1 }} />
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/pages/saju/_shell/PrimaryButton.jsx
Normal file
22
src/pages/saju/_shell/PrimaryButton.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
37
src/pages/saju/_shell/TitleBlock.jsx
Normal file
37
src/pages/saju/_shell/TitleBlock.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
21
src/pages/saju/_shell/TopRibbon.jsx
Normal file
21
src/pages/saju/_shell/TopRibbon.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
20
src/pages/saju/_shell/helpers/colorMap.js
Normal file
20
src/pages/saju/_shell/helpers/colorMap.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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] || '';
|
||||
}
|
||||
10
src/pages/saju/_shell/helpers/daeunLabel.js
Normal file
10
src/pages/saju/_shell/helpers/daeunLabel.js
Normal file
@@ -0,0 +1,10 @@
|
||||
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 '여유기';
|
||||
}
|
||||
31
src/pages/saju/_shell/helpers/deriveTraits.js
Normal file
31
src/pages/saju/_shell/helpers/deriveTraits.js
Normal file
@@ -0,0 +1,31 @@
|
||||
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 = []) {
|
||||
const sorted = Object.entries(elements || {})
|
||||
.filter(([, v]) => typeof v === 'number')
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
const traits = [];
|
||||
for (const [el, score] of sorted) {
|
||||
if (score >= 30 && TRAIT_DEFS[el]) {
|
||||
traits.push(TRAIT_DEFS[el]);
|
||||
}
|
||||
}
|
||||
if (!traits.find((t) => t.id === 'will')) traits.push(WILL_TRAIT);
|
||||
|
||||
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);
|
||||
}
|
||||
48
src/pages/saju/_shell/helpers/helpers.test.js
Normal file
48
src/pages/saju/_shell/helpers/helpers.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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');
|
||||
expect(traits.map((t) => t.id)).toContain('lead');
|
||||
});
|
||||
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)');
|
||||
});
|
||||
});
|
||||
6
src/pages/saju/_shell/helpers/hexA.js
Normal file
6
src/pages/saju/_shell/helpers/hexA.js
Normal file
@@ -0,0 +1,6 @@
|
||||
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})`;
|
||||
}
|
||||
113
src/pages/saju/_shell/shell.css
Normal file
113
src/pages/saju/_shell/shell.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* 호령 사주 v2 — 배경 + ornament + animation */
|
||||
|
||||
/* paper texture */
|
||||
.saju-v2 .paper-bg {
|
||||
background:
|
||||
linear-gradient(rgba(247, 242, 232, 0.86), rgba(251, 247, 239, 0.92)),
|
||||
url('/images/saju/horyung/background.png') center top / cover no-repeat,
|
||||
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:
|
||||
linear-gradient(rgba(251, 247, 239, 0.82), rgba(244, 236, 219, 0.9)),
|
||||
url('/images/saju/horyung/background.png') center top / cover no-repeat,
|
||||
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>");
|
||||
}
|
||||
|
||||
.saju-v2 .k-frame {
|
||||
position: relative;
|
||||
background: rgba(251, 247, 239, 0.9);
|
||||
border: 1px solid rgba(31, 42, 68, 0.10);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset, 0 8px 28px rgba(31, 42, 68, 0.05);
|
||||
}
|
||||
|
||||
.saju-v2 .k-frame::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.16);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.saju-v2 .k-frame.dark {
|
||||
background: #1F2A44;
|
||||
border: 1px solid rgba(212, 175, 55, 0.4);
|
||||
color: #F7F2E8;
|
||||
box-shadow: 0 1px 0 rgba(212, 175, 55, 0.2) inset, 0 12px 40px rgba(31, 42, 68, 0.2);
|
||||
}
|
||||
|
||||
.saju-v2 .k-frame.dark::before {
|
||||
border-color: rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
|
||||
/* 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: 0;
|
||||
}
|
||||
}
|
||||
73
src/pages/saju/_shell/tokens.css
Normal file
73
src/pages/saju/_shell/tokens.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* 호령 사주 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; }
|
||||
16
src/pages/saju/_shell/useViewportMode.js
Normal file
16
src/pages/saju/_shell/useViewportMode.js
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
32
src/pages/saju/_shell/useViewportMode.test.js
Normal file
32
src/pages/saju/_shell/useViewportMode.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, 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');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const ICON = {
|
||||
today: '☀',
|
||||
heart: '♥',
|
||||
book: '📖',
|
||||
};
|
||||
|
||||
export default function ActionCard({ to, icon, title, desc, variant = 'saju', disabled = false }) {
|
||||
const cls = `saju-action-card saju-action-card--${variant}`;
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cls} aria-disabled="true">
|
||||
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
||||
<span className="saju-action-card__title">{title}</span>
|
||||
<span className="saju-action-card__desc">{desc || '준비 중'}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={to} className={cls}>
|
||||
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
||||
<span className="saju-action-card__title">{title}</span>
|
||||
<span className="saju-action-card__desc">{desc}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ELEMENT_ORDER = ['木', '火', '土', '金', '水'];
|
||||
const ELEMENT_KR = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
|
||||
|
||||
export default function ElementBarChart({ scores }) {
|
||||
if (!scores) return null;
|
||||
const max = Math.max(...Object.values(scores), 1);
|
||||
return (
|
||||
<div className="saju-element-bars">
|
||||
{ELEMENT_ORDER.map((e) => {
|
||||
const value = scores[e] || 0;
|
||||
const widthPct = (value / max) * 100;
|
||||
return (
|
||||
<div key={e} className="saju-element-bar">
|
||||
<div className="saju-element-bar__label">{e} ({ELEMENT_KR[e]})</div>
|
||||
<div className="saju-element-bar__track">
|
||||
<div
|
||||
className={`saju-element-bar__fill saju-element-bar__fill--${e}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="saju-element-bar__value">{value.toFixed(1)}%</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function FortuneRing({ score, max = 100 }) {
|
||||
const radius = 80;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const safe = Math.max(0, Math.min(score || 0, max));
|
||||
const dashOffset = circumference - (safe / max) * circumference;
|
||||
|
||||
return (
|
||||
<div className="saju-fortune-ring">
|
||||
<svg viewBox="0 0 200 200">
|
||||
<circle
|
||||
cx="100" cy="100" r={radius}
|
||||
stroke="var(--saju-paper)" strokeWidth="14" fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="100" cy="100" r={radius}
|
||||
stroke="var(--saju-gold)" strokeWidth="14" fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 100 100)"
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', textAlign: 'center' }}>
|
||||
<div className="saju-fortune-ring__score">{safe}</div>
|
||||
<div className="saju-fortune-ring__total">/ {max}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const POSE_TO_FILE = {
|
||||
front: '/images/saju/horyung/horyung-front.png',
|
||||
bust: '/images/saju/horyung/horyung-bust.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 HoryungMascot({ pose = 'front', size = 'lg', className = '' }) {
|
||||
const src = POSE_TO_FILE[pose] || POSE_TO_FILE.front;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="호령"
|
||||
className={`horyung-mascot horyung-mascot--${size} ${className}`}
|
||||
onError={(e) => { e.target.style.visibility = 'hidden'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import HoryungMascot from './HoryungMascot';
|
||||
|
||||
export default function HoryungQuote({ pose = 'thinking', text }) {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="saju-horyung-quote">
|
||||
<HoryungMascot pose={pose} size="sm" />
|
||||
<div className="saju-horyung-quote__text">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export default function InterpretAccordion({ items }) {
|
||||
const [openKey, setOpenKey] = useState(items?.[0]?.key);
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<div className="saju-interpret-accordion">
|
||||
{items.map((it) => {
|
||||
const isOpen = openKey === it.key;
|
||||
return (
|
||||
<div key={it.key} className="saju-interpret-item">
|
||||
<div
|
||||
className="saju-interpret-item__header"
|
||||
onClick={() => setOpenKey(isOpen ? null : it.key)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }}
|
||||
>
|
||||
<span>{it.title || it.key}</span>
|
||||
<span aria-hidden>{isOpen ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="saju-interpret-item__body">
|
||||
<p style={{ margin: 0 }}>{it.content}</p>
|
||||
{it.evidence && (
|
||||
<div className="saju-interpret-item__evidence">
|
||||
<strong>근거:</strong> {it.evidence.saju_element}<br />
|
||||
<strong>해석 논리:</strong> {it.evidence.reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function LuckyBox({ lucky }) {
|
||||
if (!lucky) return null;
|
||||
return (
|
||||
<div className="saju-lucky-box">
|
||||
<div className="saju-lucky-box__item">
|
||||
<div className="saju-lucky-box__label">럭키 컬러</div>
|
||||
<div className="saju-lucky-box__value">{(lucky.color || []).join(' · ')}</div>
|
||||
</div>
|
||||
<div className="saju-lucky-box__item">
|
||||
<div className="saju-lucky-box__label">럭키 숫자</div>
|
||||
<div className="saju-lucky-box__value">{lucky.number}</div>
|
||||
</div>
|
||||
<div className="saju-lucky-box__item">
|
||||
<div className="saju-lucky-box__label">럭키 방향</div>
|
||||
<div className="saju-lucky-box__value">{lucky.direction}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const LABEL_COLOR = {
|
||||
'성장': '#4B7065',
|
||||
'안정': '#D4A574',
|
||||
'변동': '#6A5285',
|
||||
'도전': '#C58F76',
|
||||
'정체': '#888',
|
||||
};
|
||||
|
||||
export default function MonthlyFlow({ flow }) {
|
||||
if (!flow || flow.length === 0) return null;
|
||||
return (
|
||||
<div className="saju-monthly-flow">
|
||||
{flow.map((m) => (
|
||||
<div key={m.month} className="saju-monthly-flow__cell">
|
||||
<span className="saju-monthly-flow__month">{m.month}월</span>
|
||||
<span className="saju-monthly-flow__score" style={{ color: LABEL_COLOR[m.label] }}>
|
||||
{m.score}
|
||||
</span>
|
||||
<span className="saju-monthly-flow__label">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SajuInputForm({ form, onChange, onSubmit, loading, error }) {
|
||||
return (
|
||||
<form className="saju-form" onSubmit={onSubmit}>
|
||||
<h3 className="saju-h3" style={{ color: 'var(--saju-cream)', marginBottom: '0.5rem' }}>
|
||||
사주풀이 시작하기
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="이름 (선택)"
|
||||
value={form.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
||||
<input type="number" placeholder="년 (1900-2100)" value={form.year}
|
||||
onChange={(e) => onChange('year', e.target.value)} disabled={loading} min="1900" max="2100" />
|
||||
<input type="number" placeholder="월" value={form.month}
|
||||
onChange={(e) => onChange('month', e.target.value)} disabled={loading} min="1" max="12" />
|
||||
<input type="number" placeholder="일" value={form.day}
|
||||
onChange={(e) => onChange('day', e.target.value)} disabled={loading} min="1" max="31" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
||||
<input type="number" placeholder="시 (선택, 0-23)" value={form.hour}
|
||||
onChange={(e) => onChange('hour', e.target.value)} disabled={loading} min="0" max="23" />
|
||||
<select value={form.gender} onChange={(e) => onChange('gender', e.target.value)} disabled={loading}>
|
||||
<option value="male">남</option>
|
||||
<option value="female">여</option>
|
||||
</select>
|
||||
<select value={form.calendar_type} onChange={(e) => onChange('calendar_type', e.target.value)} disabled={loading}>
|
||||
<option value="solar">양력</option>
|
||||
<option value="lunar">음력</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className="saju-form__error">{error}</div>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '호령이 풀어보는 중...' : '사주풀이 시작하기 ✦'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
export default function SajuNav() {
|
||||
return (
|
||||
<nav className="saju-nav" aria-label="호령 사주">
|
||||
<Link to="/saju" className="saju-nav__logo">호령사주</Link>
|
||||
<ul className="saju-nav__links">
|
||||
<li><NavLink to="/saju/today">오늘의 운세</NavLink></li>
|
||||
<li><NavLink to="/saju/compatibility">궁합보기</NavLink></li>
|
||||
<li><NavLink to="/saju/result">사주풀이</NavLink></li>
|
||||
</ul>
|
||||
<Link to="/saju" className="saju-nav__cta">사주풀이 시작하기</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
|
||||
|
||||
export default function SajuPillars({ saju }) {
|
||||
if (!saju) return null;
|
||||
const pillars = ['year', 'month', 'day', 'hour'];
|
||||
return (
|
||||
<div className="saju-pillars">
|
||||
{pillars.map((p) => {
|
||||
const data = saju[p];
|
||||
if (!data) {
|
||||
return (
|
||||
<div key={p} className="saju-pillar">
|
||||
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
|
||||
<div style={{ opacity: 0.4 }}>-</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={p} className="saju-pillar">
|
||||
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
|
||||
<div>
|
||||
<span className="saju-pillar__stem">{data.stem}</span>
|
||||
<span className="saju-pillar__stem-kr"> ({data.stem_kr})</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="saju-pillar__branch">{data.branch}</span>
|
||||
<span className="saju-pillar__branch-kr"> ({data.branch_kr})</span>
|
||||
</div>
|
||||
<div className="saju-pillar__ten-god">{data.ten_god}</div>
|
||||
<div className="saju-pillar__fortune">{data.fortune}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ICON_BY_CATEGORY = {
|
||||
wealth: '💰',
|
||||
romance: '💖',
|
||||
social: '🤝',
|
||||
career: '💼',
|
||||
};
|
||||
|
||||
const COLOR_VAR_BY_CATEGORY = {
|
||||
wealth: 'var(--saju-wealth)',
|
||||
romance: 'var(--saju-romance)',
|
||||
social: 'var(--saju-social)',
|
||||
career: 'var(--saju-career)',
|
||||
};
|
||||
|
||||
const TITLE_BY_CATEGORY = {
|
||||
wealth: '재물운',
|
||||
romance: '연애운',
|
||||
social: '인간관계',
|
||||
career: '직장운',
|
||||
};
|
||||
|
||||
export default function ScoreCard({ category, score }) {
|
||||
const safe = Math.max(0, Math.min(score || 0, 100));
|
||||
return (
|
||||
<div className="saju-score-card">
|
||||
<div className="saju-score-card__head">
|
||||
<span className="saju-score-card__icon">{ICON_BY_CATEGORY[category]}</span>
|
||||
<span className="saju-score-card__title">{TITLE_BY_CATEGORY[category]}</span>
|
||||
</div>
|
||||
<div className="saju-score-card__value">{safe}<small style={{ fontSize: '1rem', opacity: 0.5 }}>/100</small></div>
|
||||
<div className="saju-score-card__bar">
|
||||
<div style={{ width: `${safe}%`, background: COLOR_VAR_BY_CATEGORY[category] }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/pages/saju/sampleReading.js
Normal file
51
src/pages/saju/sampleReading.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const sampleReading = {
|
||||
id: null,
|
||||
name: '홍길동',
|
||||
birth_year: 1990,
|
||||
birth_month: 5,
|
||||
birth_day: 20,
|
||||
birth_hour: 10,
|
||||
gender: 'male',
|
||||
calendar_type: 'solar',
|
||||
birth_place: '서울특별시',
|
||||
saju_data: {
|
||||
year: { stem: '己', stem_kr: '음토', branch: '巳', branch_kr: '사화', ten_god: '정인', fortune: '丙 庚 戊' },
|
||||
month: { stem: '丙', stem_kr: '양화', branch: '子', branch_kr: '자수', ten_god: '편관', fortune: '壬 癸' },
|
||||
day: { stem: '庚', stem_kr: '양금', branch: '申', branch_kr: '신금', ten_god: '-', fortune: '庚 壬 戊' },
|
||||
hour: { stem: '辛', stem_kr: '음금', branch: '巳', branch_kr: '사화', ten_god: '겁재', fortune: '丙 庚 戊' },
|
||||
},
|
||||
analysis_data: {
|
||||
element_scores: { '木': 20, '火': 35, '土': 25, '金': 55, '水': 30 },
|
||||
day_master_strength: { result: '강함', score: 78, reasons: ['금 기운 우세', '일간 중심 안정'] },
|
||||
},
|
||||
fortune_scores: {
|
||||
overall: 78,
|
||||
wealth: 80,
|
||||
romance: 70,
|
||||
social: 75,
|
||||
career: 82,
|
||||
},
|
||||
lucky: {
|
||||
color: ['#1F2A44', '#E8C76B', '#6B4423', '#D89098', '#F7F2E8'],
|
||||
number: 8,
|
||||
direction: '동쪽',
|
||||
time: '오전 10시 ~ 12시',
|
||||
good_signs: ['작은 기회가 큰 흐름으로 이어질 수 있어요.'],
|
||||
warnings: ['충동적인 결정은 피하고 여유를 가지세요.'],
|
||||
},
|
||||
daeun_data: [
|
||||
{ age: 0, start_year: 1990, end_year: 1999, stem: '戊', branch: '戌' },
|
||||
{ age: 10, start_year: 2000, end_year: 2009, stem: '丁', branch: '酉' },
|
||||
{ age: 20, start_year: 2010, end_year: 2019, stem: '丙', branch: '申' },
|
||||
{ age: 30, start_year: 2020, end_year: 2029, stem: '乙', branch: '未' },
|
||||
{ age: 40, start_year: 2030, end_year: 2039, stem: '甲', branch: '午' },
|
||||
{ age: 50, start_year: 2040, end_year: 2049, stem: '癸', branch: '巳' },
|
||||
{ age: 60, start_year: 2050, end_year: 2059, stem: '壬', branch: '辰' },
|
||||
{ age: 70, start_year: 2060, end_year: 2069, stem: '辛', branch: '卯' },
|
||||
],
|
||||
interpretation_json: {
|
||||
summary: '당신은 강한 의지와 추진력을 가진 분입니다. 새로운 것을 두려워하지 않고 도전하는 용기가 큰 장점이며, 주변 사람에게 신뢰감을 주는 리더형의 흐름이 보입니다.',
|
||||
},
|
||||
};
|
||||
|
||||
export default sampleReading;
|
||||
258
src/pages/saju/views/home.desktop.jsx
Normal file
258
src/pages/saju/views/home.desktop.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Mascot from '../_shell/Mascot';
|
||||
import OrnateFrame from '../_shell/OrnateFrame';
|
||||
import PanelHeader from '../_shell/PanelHeader';
|
||||
import DesktopFooter from '../_shell/DesktopFooter';
|
||||
import PrimaryButton from '../_shell/PrimaryButton';
|
||||
import {
|
||||
IconChevron, IconHeart, IconMoney, IconPaw, IconSparkle, IconSun, IconUser, IconYinYang,
|
||||
} from '../_shell/Icons';
|
||||
import useSajuForm from '../hooks/useSajuForm';
|
||||
|
||||
const inputStyle = {
|
||||
flex: 1, padding: '8px 10px', border: '1px solid rgba(247,242,232,0.16)',
|
||||
borderRadius: 8, background: 'rgba(247,242,232,0.08)', color: '#F7F2E8',
|
||||
fontSize: 13, fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function dateValue(form) {
|
||||
if (!form.year || !form.month || !form.day) return '';
|
||||
return `${form.year}-${pad(form.month)}-${pad(form.day)}`;
|
||||
}
|
||||
function timeValue(form) {
|
||||
if (form.hour === '' || form.hour == null) return '';
|
||||
return `${pad(form.hour)}:00`;
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
{ to: '/saju/today', icon: IconSun, title: '오늘의 운세', desc: '오늘의 흐름과 운세를 한눈에 확인하세요.', color: '#D4AF37' },
|
||||
{ to: '/saju/compatibility', icon: IconHeart, title: '궁합보기', desc: '소중한 인연과 궁합을 확인해 보세요.', color: '#D89098' },
|
||||
{ to: '/saju/result', icon: IconYinYang, title: '사주풀이', desc: '내 사주의 구조와 운세를 자세히 풀이해 드립니다.', color: '#3A5A8C' },
|
||||
];
|
||||
|
||||
export default function HomeDesktop() {
|
||||
const navigate = useNavigate();
|
||||
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||||
|
||||
const onDate = (e) => {
|
||||
const value = e.target.value;
|
||||
if (!value) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; }
|
||||
const [year, month, day] = value.split('-');
|
||||
handleChange('year', year);
|
||||
handleChange('month', String(parseInt(month, 10)));
|
||||
handleChange('day', String(parseInt(day, 10)));
|
||||
};
|
||||
|
||||
const onTime = (e) => {
|
||||
const value = e.target.value;
|
||||
if (!value) { handleChange('hour', ''); return; }
|
||||
const [hour] = value.split(':');
|
||||
handleChange('hour', String(parseInt(hour, 10)));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="page mt-wash screen-in" style={{ marginTop: -78, paddingTop: 88 }}>
|
||||
<section style={{
|
||||
maxWidth: 1400, margin: '0 auto', minHeight: 540,
|
||||
padding: '36px 48px 0', position: 'relative', overflow: 'visible',
|
||||
border: '1px solid rgba(31,42,68,0.10)', borderRadius: 32,
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(251,247,239,0.62) 0%, rgba(251,247,239,0.82) 52%, rgba(251,247,239,0.94) 100%), url('/images/saju/horyung/background.png') center top / cover no-repeat",
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.75)',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.15fr', gap: 34, alignItems: 'center', minHeight: 480 }}>
|
||||
<div style={{ position: 'relative', alignSelf: 'stretch' }}>
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 142, zIndex: 2,
|
||||
background: 'rgba(251,247,239,0.86)', border: '1px solid rgba(31,42,68,0.12)',
|
||||
borderRadius: 24, padding: '18px 22px', width: 210,
|
||||
boxShadow: '0 8px 22px rgba(31,42,68,0.08)',
|
||||
color: '#1F2A44', fontSize: 14, lineHeight: 1.7, letterSpacing: '-0.02em',
|
||||
}}>
|
||||
안녕하세요!<br />저는 호령이에요.<br />당신의 길을 비춰드릴게요.
|
||||
<div style={{ textAlign: 'right', color: '#B89530', marginTop: 4 }}><IconPaw size={12} /></div>
|
||||
</div>
|
||||
<Mascot variant="full" size={430} style={{
|
||||
position: 'absolute', left: 110, bottom: -20,
|
||||
filter: 'drop-shadow(0 16px 44px rgba(31,42,68,0.18))',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '50px 0 134px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: '#A67B3F', marginBottom: 14 }}>
|
||||
<IconSparkle size={14} color="#B89530" />
|
||||
<span style={{ fontSize: 15, fontWeight: 800, letterSpacing: '-0.01em' }}>전통 명리학 × AI 인사이트</span>
|
||||
</div>
|
||||
<h1 className="font-title" style={{
|
||||
margin: 0, fontSize: 56, lineHeight: 1.18,
|
||||
color: '#1F2A44', letterSpacing: '-0.055em',
|
||||
}}>
|
||||
호령이 반갑게<br />
|
||||
맞이하는<br />
|
||||
<span style={{ color: '#A67B3F', whiteSpace: 'nowrap' }}>오늘의 사주</span>
|
||||
</h1>
|
||||
<p style={{
|
||||
margin: '22px 0 0', maxWidth: 560, fontSize: 17,
|
||||
color: '#202638', lineHeight: 1.75, letterSpacing: '-0.02em',
|
||||
}}>
|
||||
오랜 지혜와 AI 분석으로 정확하고 깊이 있는 당신만의 운명을 안내해 드립니다.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
marginTop: 34, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0,
|
||||
maxWidth: 520, border: '1px solid rgba(31,42,68,0.10)',
|
||||
borderRadius: 28, background: 'rgba(251,247,239,0.78)', overflow: 'hidden',
|
||||
}}>
|
||||
<MiniTrust icon={<IconYinYang size={22} stroke="#B89530" />} title="전통 명리학 기반" desc="정통 사주 해석" />
|
||||
<MiniTrust icon={<IconSparkle size={20} color="#3A5A8C" />} title="AI 분석 인사이트" desc="정확한 인사이트" />
|
||||
<MiniTrust icon={<IconUser size={22} stroke="#3A5A8C" />} title="개인정보 보호" desc="안심 서비스" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute', left: 48, right: 48, bottom: -40,
|
||||
background: '#1F2A44', borderRadius: 22, padding: '18px 22px',
|
||||
border: '1px solid rgba(212,175,55,0.35)',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16,
|
||||
boxShadow: '0 18px 36px rgba(31,42,68,0.22)',
|
||||
zIndex: 3,
|
||||
}}>
|
||||
{FEATURES.map((feature) => (
|
||||
<button key={feature.title} onClick={() => navigate(feature.to)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 18, textAlign: 'left',
|
||||
background: '#FBF7EF', color: '#1F2A44',
|
||||
border: '1px solid rgba(212,175,55,0.42)', borderRadius: 16,
|
||||
padding: '18px 20px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 58, height: 58, borderRadius: '50%',
|
||||
background: `${feature.color}26`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: `1px solid ${feature.color}66`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<feature.icon size={30} stroke={feature.color} strokeWidth={1.6} />
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span className="font-title" style={{ display: 'block', fontSize: 24, letterSpacing: '-0.04em' }}>{feature.title}</span>
|
||||
<span style={{ display: 'block', marginTop: 5, fontSize: 13, lineHeight: 1.5, color: '#3E4456', letterSpacing: '-0.02em' }}>{feature.desc}</span>
|
||||
</span>
|
||||
<IconChevron dir="right" size={16} color="#B89530" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{
|
||||
maxWidth: 1160, margin: '88px auto 0', padding: '0 24px',
|
||||
display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', gap: 24,
|
||||
}}>
|
||||
<OrnateFrame color="#D4AF37" bg="rgba(251,247,239,0.86)" radius={18} padding="22px 22px" double>
|
||||
<PanelHeader title="오늘의 운세 한눈에 보기" />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 20, alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center', borderRight: '1px solid rgba(31,42,68,0.08)' }}>
|
||||
<div className="font-title" style={{ fontSize: 58, color: '#1F2A44', lineHeight: 1 }}>78</div>
|
||||
<div style={{ fontSize: 18, color: '#1F2A44' }}>/100</div>
|
||||
<div style={{ marginTop: 8, fontSize: 14, fontWeight: 700, color: '#1F2A44' }}>종합운</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-title" style={{ fontSize: 22, color: '#B89530', letterSpacing: '-0.03em' }}>
|
||||
새로운 기회가 찾아오는 날입니다.
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 14px', color: '#3E4456', fontSize: 13, lineHeight: 1.7 }}>
|
||||
작은 실천이 큰 변화를 만듭니다. 주변의 조언에 귀 기울여 보세요.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||
<ScorePill icon={<IconMoney size={15} stroke="#D4AF37" />} label="재물운" value="80" />
|
||||
<ScorePill icon={<IconHeart size={15} stroke="#D89098" />} label="연애운" value="70" />
|
||||
<ScorePill icon={<IconSun size={15} stroke="#4E6B5C" />} label="건강운" value="75" />
|
||||
<ScorePill icon={<IconUser size={15} stroke="#3A5A8C" />} label="직장운" value="82" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OrnateFrame>
|
||||
|
||||
<OrnateFrame color="#D4AF37" bg="#1F2A44" radius={18} padding="22px 28px" double>
|
||||
<form onSubmit={handleSubmit} style={{ color: '#F7F2E8' }}>
|
||||
<div className="font-title" style={{ fontSize: 22, color: '#E8C76B', textAlign: 'center', letterSpacing: '-0.03em' }}>
|
||||
사주풀이를 시작해 보세요
|
||||
</div>
|
||||
<div style={{ marginTop: 6, color: '#D9D2C0', fontSize: 13, textAlign: 'center' }}>
|
||||
정확한 사주 분석을 위해 생년월일시를 입력해 주세요.
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 10px' }}>
|
||||
<DarkInputRow label="이름">
|
||||
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)} placeholder="홍길동" style={inputStyle} />
|
||||
</DarkInputRow>
|
||||
<DarkInputRow label="생년월일">
|
||||
<input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} />
|
||||
</DarkInputRow>
|
||||
<DarkInputRow label="시간">
|
||||
<input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} />
|
||||
</DarkInputRow>
|
||||
<DarkInputRow label="성별">
|
||||
<select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)} style={inputStyle}>
|
||||
<option value="male">남</option>
|
||||
<option value="female">여</option>
|
||||
</select>
|
||||
</DarkInputRow>
|
||||
</div>
|
||||
<DarkInputRow label="달력">
|
||||
<select value={form.calendar_type} onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}>
|
||||
<option value="solar">양력</option>
|
||||
<option value="lunar">음력</option>
|
||||
</select>
|
||||
</DarkInputRow>
|
||||
{error && <div style={{ marginTop: 10, fontSize: 12, color: '#F2C7CD', textAlign: 'center' }}>{error}</div>}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<PrimaryButton color="#D9AD61" type="submit" style={{ color: '#1F2A44' }}>
|
||||
{loading ? '호령이 풀이 중...' : '사주풀이 시작하기'}
|
||||
{!loading && <IconPaw size={14} color="#1F2A44" />}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</OrnateFrame>
|
||||
</section>
|
||||
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniTrust({ icon, title, desc }) {
|
||||
return (
|
||||
<div style={{ padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12, borderRight: '1px solid rgba(31,42,68,0.08)' }}>
|
||||
<span style={{ width: 38, height: 38, borderRadius: '50%', background: 'rgba(31,42,68,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{icon}</span>
|
||||
<span>
|
||||
<span style={{ display: 'block', fontSize: 12, color: '#1F2A44', fontWeight: 800, whiteSpace: 'nowrap' }}>{title}</span>
|
||||
<span style={{ display: 'block', marginTop: 2, fontSize: 11, color: '#6B6B6B', whiteSpace: 'nowrap' }}>{desc}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScorePill({ icon, label, value }) {
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid rgba(31,42,68,0.10)', borderRadius: 12,
|
||||
background: 'rgba(251,247,239,0.82)', padding: '9px 10px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{icon}
|
||||
<span style={{ fontSize: 12, color: '#1F2A44', fontWeight: 700 }}>{label}</span>
|
||||
<span className="font-title" style={{ marginLeft: 'auto', color: '#1F2A44', fontSize: 17 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DarkInputRow({ label, children }) {
|
||||
return (
|
||||
<label style={{ display: 'grid', gap: 5 }}>
|
||||
<span style={{ fontSize: 11, color: '#D9D2C0', fontWeight: 700 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
133
src/pages/saju/views/home.mobile.jsx
Normal file
133
src/pages/saju/views/home.mobile.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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' },
|
||||
];
|
||||
|
||||
const inputStyle = {
|
||||
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44',
|
||||
fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function dateValue(form) {
|
||||
if (!form.year || !form.month || !form.day) return '';
|
||||
return `${form.year}-${pad(form.month)}-${pad(form.day)}`;
|
||||
}
|
||||
|
||||
function timeValue(form) {
|
||||
if (form.hour === '' || form.hour == null) return '';
|
||||
return `${pad(form.hour)}:00`;
|
||||
}
|
||||
|
||||
export default function HomeMobile() {
|
||||
const navigate = useNavigate();
|
||||
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||||
|
||||
const onDate = (e) => {
|
||||
const v = e.target.value;
|
||||
if (!v) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; }
|
||||
const [y, m, d] = v.split('-');
|
||||
handleChange('year', y);
|
||||
handleChange('month', String(parseInt(m, 10)));
|
||||
handleChange('day', String(parseInt(d, 10)));
|
||||
};
|
||||
|
||||
const onTime = (e) => {
|
||||
const v = e.target.value;
|
||||
if (!v) { handleChange('hour', ''); return; }
|
||||
const [h] = v.split(':');
|
||||
handleChange('hour', String(parseInt(h, 10)));
|
||||
};
|
||||
|
||||
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="이름">
|
||||
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="홍길동" style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="생년월일">
|
||||
<input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="시간">
|
||||
<input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="성별">
|
||||
<select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)}
|
||||
style={inputStyle}>
|
||||
<option value="male">남</option>
|
||||
<option value="female">여</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InputRow label="달력">
|
||||
<select value={form.calendar_type}
|
||||
onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
232
src/pages/saju/views/match-result.desktop.jsx
Normal file
232
src/pages/saju/views/match-result.desktop.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DesktopHero from '../_shell/DesktopHero';
|
||||
import DesktopFooter from '../_shell/DesktopFooter';
|
||||
import PanelHeader from '../_shell/PanelHeader';
|
||||
import {
|
||||
IconChevron, IconHeart, IconPaw, IconSparkle, IconSun, IconUser,
|
||||
} from '../_shell/Icons';
|
||||
import hexA from '../_shell/helpers/hexA';
|
||||
|
||||
export default function MatchResultDesktop({ result }) {
|
||||
const navigate = useNavigate();
|
||||
const interp = result?.interpretation_json || {};
|
||||
const score = Math.round(result?.score || interp.score || 86);
|
||||
const names = {
|
||||
a: result?.person_a?.name || '나',
|
||||
b: result?.person_b?.name || '상대방',
|
||||
};
|
||||
const strengths = interp.strengths?.length ? interp.strengths : ['서로에게 긍정적인 영향을 주며 함께 목표를 이루기 좋아요.'];
|
||||
const challenges = interp.challenges?.length ? interp.challenges : ['감정 표현 방식이 달라 오해가 생길 수 있으니 배려가 필요해요.'];
|
||||
const summary = interp.summary || '두 분은 서로의 부족한 부분을 채워주며 함께 성장해 나갈 수 있는 좋은 인연이에요. 서로의 다름을 인정하고 존중한다면 더욱 길고 단단한 관계로 발전할 수 있습니다.';
|
||||
|
||||
return (
|
||||
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
|
||||
<DesktopHero
|
||||
title="궁합보기"
|
||||
subtitle="소중한 인연의 흐름을 살펴보세요."
|
||||
accent="#4E6B5C"
|
||||
bubble={<div>두 분의 인연을<br />제가 잘 살펴봤어요!<br />함께 행복한 길을 걸어가세요.</div>}
|
||||
/>
|
||||
|
||||
<div style={{ maxWidth: 1320, margin: '0 auto', padding: '0 36px 36px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 18, alignItems: 'center' }}>
|
||||
<PersonSummary label="나" name={names.a} chipColor="#4E6B5C" chipBg="#E6EBE5" />
|
||||
<div style={{
|
||||
width: 70, height: 70, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #F2C7CD, #D89098)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(216,144,152,0.38), inset 0 1px 0 rgba(255,255,255,0.5)',
|
||||
}}>
|
||||
<IconHeart size={34} stroke="#FFF" strokeWidth={2} />
|
||||
</div>
|
||||
<PersonSummary label="상대방" name={names.b} chipColor="#D89098" chipBg="#FBE8EB" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, display: 'grid', gridTemplateColumns: '360px 1fr', gap: 18 }}>
|
||||
<div className="k-frame dark" style={{
|
||||
padding: 28, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#E8C76B' }}>
|
||||
<span style={{ width: 28, height: 1, background: '#E8C76B' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.12em' }}>궁합 점수</span>
|
||||
<span style={{ width: 28, height: 1, background: '#E8C76B' }} />
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 82, color: '#F7F2E8', letterSpacing: '-0.05em', lineHeight: 1 }}>
|
||||
{score}<span style={{ fontSize: 28, color: '#E8C76B', fontWeight: 400 }}>점</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#D9D2C0' }}>
|
||||
상위권의 좋은 궁합이에요.
|
||||
</div>
|
||||
<div style={{ width: '84%', height: 7, borderRadius: 999, background: 'rgba(247,242,232,0.1)', marginTop: 8, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: 'linear-gradient(90deg, #B89530, #E8C76B, #D89098)', borderRadius: 999 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||
<SubScoreCard color="#D4AF37" icon={<IconSun size={18} stroke="#D4AF37" />} label="성향 궁합" score={Math.min(100, score + 2)} desc="가치관과 성향이 조화를 이룹니다." />
|
||||
<SubScoreCard color="#3A5A8C" icon={<SpeechIcon size={18} stroke="#3A5A8C" />} label="대화 궁합" score={Math.max(0, score - 4)} desc="편안한 대화를 나눌 수 있어요." />
|
||||
<SubScoreCard color="#D89098" icon={<IconHeart size={18} stroke="#D89098" />} label="연애 궁합" score={Math.min(100, score + 4)} desc="설렘과 안정감을 함께 줍니다." />
|
||||
<SubScoreCard color="#A67B3F" icon={<RingIcon />} label="결혼 궁합" score={Math.max(0, score - 2)} desc="함께 미래를 그리기 좋은 균형입니다." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, display: 'grid', gridTemplateColumns: '1fr 1.4fr 1fr', gap: 18 }}>
|
||||
<div className="k-frame" style={{ padding: '22px 24px' }}>
|
||||
<PanelHeader title="오행 균형" accent="#4E6B5C" />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', padding: '8px 0 18px' }}>
|
||||
<Circle label="나" sub="목(木)" color="#4E6B5C" />
|
||||
<Circle label="상생의" sub="흐름" color="#1F2A44" center />
|
||||
<Circle label="상대방" sub="화(火)" color="#D89098" />
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#6B6B6B', lineHeight: 1.7, textAlign: 'center' }}>
|
||||
서로를 북돋우는 상생의 기운이 강합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="k-frame" style={{ padding: '22px 24px' }}>
|
||||
<PanelHeader title="궁합 해석" accent="#4E6B5C" />
|
||||
<div style={{ fontSize: 14, color: '#1F2A44', lineHeight: 1.85, whiteSpace: 'pre-line' }}>{summary}</div>
|
||||
<div style={{
|
||||
marginTop: 14, padding: '14px 16px', borderRadius: 12,
|
||||
background: 'rgba(78,107,92,0.06)', border: '1px dashed rgba(78,107,92,0.3)',
|
||||
fontSize: 14, color: '#4E6B5C', lineHeight: 1.65, textAlign: 'center',
|
||||
}}>
|
||||
서로에게 따뜻한 빛이 되어주는 인연입니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="k-frame" style={{ padding: '22px 24px' }}>
|
||||
<PanelHeader title="한눈에 보는 궁합 요약" accent="#4E6B5C" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<SummaryItem color="#4E6B5C" title="좋은 점" desc={strengths[0]} />
|
||||
<SummaryItem color="#D89098" title="조심할 점" desc={challenges[0]} />
|
||||
<SummaryItem color="#3A5A8C" title="추천 대화법" desc="감정을 솔직히 표현하고 상대의 이야기를 끝까지 들어주세요." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'center', gap: 14 }}>
|
||||
<button onClick={() => navigate('/saju/compatibility')} style={buttonGhost()}>
|
||||
<IconChevron dir="left" size={13} color="#1F2A44" /> 새로운 궁합 보기
|
||||
</button>
|
||||
<button onClick={() => navigate('/saju')} style={buttonPrimary()}>
|
||||
사주풀이 시작하기 <IconPaw size={13} color="#E8C76B" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonSummary({ label, name, chipColor, chipBg }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span style={{
|
||||
padding: '5px 16px', borderRadius: 999,
|
||||
background: chipBg, color: chipColor, fontSize: 13, fontWeight: 800,
|
||||
border: `1px solid ${hexA(chipColor, 0.4)}`,
|
||||
}}>{label}</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 800, color: '#1F2A44' }}>{name}</span>
|
||||
<span style={{ padding: '3px 9px', borderRadius: 8, background: 'rgba(212,175,55,0.10)', color: '#B89530', fontSize: 11, fontWeight: 800 }}>양력</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: '50%',
|
||||
background: chipBg, border: `1px solid ${hexA(chipColor, 0.35)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconUser size={24} stroke={chipColor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubScoreCard({ color, icon, label, score, desc }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '20px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: '50%',
|
||||
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>{icon}</div>
|
||||
<span className="font-title" style={{ fontSize: 18, color: '#1F2A44', letterSpacing: '-0.03em' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, marginTop: 16, borderRadius: 999, background: 'rgba(31,42,68,0.06)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${score}%`, height: '100%', background: color, borderRadius: 999 }} />
|
||||
</div>
|
||||
<div className="font-title" style={{ marginTop: 10, fontSize: 24, color, textAlign: 'center' }}>
|
||||
{score}<span style={{ fontSize: 13, color: '#1F2A44', fontWeight: 400 }}>점</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 7, fontSize: 12, color: '#6B6B6B', lineHeight: 1.55, textAlign: 'center' }}>{desc}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Circle({ label, sub, color, center }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: center ? 108 : 104, height: center ? 108 : 104, borderRadius: '50%',
|
||||
background: color, color: '#F7F2E8',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
|
||||
marginLeft: center ? -20 : 0, marginRight: center ? -20 : 0, zIndex: center ? 2 : 1,
|
||||
border: center ? '2px solid #D4AF37' : 'none',
|
||||
boxShadow: center ? '0 10px 24px rgba(31,42,68,0.18)' : 'none',
|
||||
}}>
|
||||
<span className="font-title" style={{ fontSize: center ? 14 : 18, color: center ? '#E8C76B' : '#F7F2E8' }}>{label}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.9 }}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ color, title, desc }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: hexA(color, 0.12), border: `1px solid ${hexA(color, 0.35)}`,
|
||||
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color, fontSize: 12, fontWeight: 800,
|
||||
}}>{title[0]}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#1F2A44', marginBottom: 3 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.6 }}>{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonPrimary() {
|
||||
return {
|
||||
padding: '14px 26px', borderRadius: 999, background: '#1F2A44', color: '#F7F2E8',
|
||||
border: '1px solid rgba(212,175,55,0.4)', fontSize: 14, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
};
|
||||
}
|
||||
|
||||
function buttonGhost() {
|
||||
return {
|
||||
padding: '14px 26px', borderRadius: 999, background: '#FBF7EF', color: '#1F2A44',
|
||||
border: '1px solid rgba(31,42,68,0.22)', fontSize: 14, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
};
|
||||
}
|
||||
|
||||
function SpeechIcon({ size = 16, stroke = '#3A5A8C' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 5h16v11H10l-4 4v-4H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RingIcon({ size = 18, stroke = '#A67B3F' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7">
|
||||
<path d="M9 6l3-3 3 3" />
|
||||
<circle cx="12" cy="16" r="5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
209
src/pages/saju/views/match.desktop.jsx
Normal file
209
src/pages/saju/views/match.desktop.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import DesktopHero from '../_shell/DesktopHero';
|
||||
import DesktopFooter from '../_shell/DesktopFooter';
|
||||
import PanelHeader from '../_shell/PanelHeader';
|
||||
import PrimaryButton from '../_shell/PrimaryButton';
|
||||
import {
|
||||
IconCalendar, IconClock, IconHeart, IconPaw, IconSparkle, IconUser,
|
||||
} from '../_shell/Icons';
|
||||
import hexA from '../_shell/helpers/hexA';
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function dateValue(person) {
|
||||
if (!person.year || !person.month || !person.day) return '';
|
||||
return `${person.year}-${pad(person.month)}-${pad(person.day)}`;
|
||||
}
|
||||
function timeValue(person) {
|
||||
if (person.hour === '' || person.hour == null) return '';
|
||||
return `${pad(person.hour)}:00`;
|
||||
}
|
||||
function onDate(person, onChange, event) {
|
||||
const value = event.target.value;
|
||||
if (!value) return onChange({ ...person, year: '', month: '', day: '' });
|
||||
const [year, month, day] = value.split('-');
|
||||
return onChange({ ...person, year: parseInt(year, 10), month: parseInt(month, 10), day: parseInt(day, 10) });
|
||||
}
|
||||
function onTime(person, onChange, event) {
|
||||
const value = event.target.value;
|
||||
if (!value) return onChange({ ...person, hour: null });
|
||||
const [hour] = value.split(':');
|
||||
return onChange({ ...person, hour: parseInt(hour, 10) });
|
||||
}
|
||||
|
||||
export default function MatchDesktop({
|
||||
personA, personB, onChangeA, onChangeB, onSubmit, loading, error,
|
||||
}) {
|
||||
return (
|
||||
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
|
||||
<DesktopHero
|
||||
title="궁합보기"
|
||||
subtitle="소중한 인연의 흐름을 살펴보세요."
|
||||
accent="#4E6B5C"
|
||||
bubble={<div>두 분의 인연을<br />제가 잘 살펴봐드릴게요!<br />함께 행복한 길을 걸어가시길 바라요.</div>}
|
||||
/>
|
||||
|
||||
<form onSubmit={onSubmit} style={{ maxWidth: 1320, margin: '0 auto', padding: '0 36px 36px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 72px 1fr', gap: 18, alignItems: 'center' }}>
|
||||
<PersonCard
|
||||
label="나"
|
||||
chipColor="#4E6B5C"
|
||||
chipBg="#E6EBE5"
|
||||
avatarBg="#E7ECF3"
|
||||
person={personA}
|
||||
onChange={onChangeA}
|
||||
/>
|
||||
<div style={{
|
||||
width: 68, height: 68, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #F2C7CD, #D89098)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(216,144,152,0.38), inset 0 1px 0 rgba(255,255,255,0.5)',
|
||||
border: '1px solid rgba(255,255,255,0.5)',
|
||||
}}>
|
||||
<IconHeart size={34} stroke="#FFF" strokeWidth={2} />
|
||||
</div>
|
||||
<PersonCard
|
||||
label="상대방"
|
||||
chipColor="#D89098"
|
||||
chipBg="#FBE8EB"
|
||||
avatarBg="#FBE8EB"
|
||||
person={personB}
|
||||
onChange={onChangeB}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#C04A4A', fontSize: 13, fontWeight: 700 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="k-frame" style={{ marginTop: 18, padding: '22px 28px', display: 'grid', gridTemplateColumns: '1fr auto', gap: 24, alignItems: 'center' }}>
|
||||
<div>
|
||||
<PanelHeader title="궁합 분석 준비" accent="#4E6B5C" />
|
||||
<div style={{ marginTop: -8, fontSize: 13, color: '#6B6B6B', lineHeight: 1.7 }}>
|
||||
두 사람의 사주를 바탕으로 성향, 대화, 연애, 결혼 가능성까지 다양한 측면에서 조화와 흐름을 분석합니다.
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton color="#1F2A44" type="submit" full={false} style={{ borderRadius: 999, minWidth: 220 }}>
|
||||
{loading ? '호령이 비교 중...' : '궁합보기 시작'}
|
||||
{!loading && <IconPaw size={13} color="#E8C76B" />}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ label, chipColor, chipBg, avatarBg, person, onChange }) {
|
||||
const activeGender = person.gender || 'male';
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '22px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
|
||||
<span style={{
|
||||
padding: '5px 16px', borderRadius: 999,
|
||||
background: chipBg, color: chipColor, fontSize: 14, fontWeight: 800,
|
||||
border: `1px solid ${hexA(chipColor, 0.4)}`, letterSpacing: '-0.02em',
|
||||
}}>{label}</span>
|
||||
<input
|
||||
value={person.name || ''}
|
||||
onChange={(event) => onChange({ ...person, name: event.target.value })}
|
||||
placeholder="이름"
|
||||
style={{
|
||||
flex: 1, minWidth: 120, padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid rgba(31,42,68,0.12)', background: '#FBF7EF',
|
||||
color: '#1F2A44', fontSize: 15, fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
padding: '4px 10px', borderRadius: 8, background: 'rgba(212,175,55,0.10)',
|
||||
color: '#B89530', fontSize: 11, fontWeight: 800,
|
||||
}}>{person.calendar_type === 'lunar' ? '음력' : '양력'}</span>
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
background: avatarBg, border: `1px solid ${hexA(chipColor, 0.28)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: chipColor,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<IconUser size={30} stroke={chipColor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 170px', gap: 10 }}>
|
||||
<FieldPill icon={<IconCalendar size={15} stroke="#B89530" />}>
|
||||
<input type="date" value={dateValue(person)} onChange={(event) => onDate(person, onChange, event)} style={fieldInputStyle} />
|
||||
</FieldPill>
|
||||
<select
|
||||
value={person.calendar_type}
|
||||
onChange={(event) => onChange({ ...person, calendar_type: event.target.value })}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="solar">양력</option>
|
||||
<option value="lunar">음력</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: 10, marginTop: 10 }}>
|
||||
<FieldPill icon={<IconClock size={15} stroke="#B89530" />}>
|
||||
<input type="time" value={timeValue(person)} onChange={(event) => onTime(person, onChange, event)} style={fieldInputStyle} />
|
||||
</FieldPill>
|
||||
<div style={{ display: 'flex', borderRadius: 10, overflow: 'hidden', border: '1px solid rgba(31,42,68,0.12)' }}>
|
||||
{[
|
||||
['male', '남'],
|
||||
['female', '여'],
|
||||
].map(([value, text]) => {
|
||||
const active = activeGender === value;
|
||||
return (
|
||||
<button key={value} type="button" onClick={() => onChange({ ...person, gender: value })} style={{
|
||||
flex: 1, border: 'none', padding: '11px 0',
|
||||
background: active ? chipColor : '#FBF7EF',
|
||||
color: active ? '#F7F2E8' : '#6B6B6B',
|
||||
fontSize: 13, fontWeight: 800,
|
||||
}}>{text}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => onChange({ name: '', year: '', month: '', day: '', hour: null, gender: activeGender, calendar_type: 'solar' })} style={{
|
||||
margin: '14px auto 0', display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'transparent', border: 'none', color: '#6B6B6B', fontSize: 12, fontWeight: 700,
|
||||
}}>
|
||||
<IconSparkle size={11} color="#B89530" /> 다시 입력하기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldPill({ icon, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '0 12px', borderRadius: 10,
|
||||
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.12)',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{icon}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldInputStyle = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#1F2A44',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
padding: '11px 0',
|
||||
};
|
||||
|
||||
const selectStyle = {
|
||||
border: '1px solid rgba(31,42,68,0.12)',
|
||||
borderRadius: 10,
|
||||
background: '#FBF7EF',
|
||||
color: '#1F2A44',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
padding: '0 12px',
|
||||
};
|
||||
108
src/pages/saju/views/match.mobile.jsx
Normal file
108
src/pages/saju/views/match.mobile.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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';
|
||||
|
||||
const inputStyle = {
|
||||
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44',
|
||||
fontFamily: 'inherit',
|
||||
};
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function dateValue(p) {
|
||||
if (!p.year || !p.month || !p.day) return '';
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||||
}
|
||||
function timeValue(p) {
|
||||
if (p.hour === '' || p.hour == null) return '';
|
||||
return `${pad(p.hour)}:00`;
|
||||
}
|
||||
function onDate(p, set, e) {
|
||||
const v = e.target.value;
|
||||
if (!v) return set({ ...p, year: '', month: '', day: '' });
|
||||
const [y, m, d] = v.split('-');
|
||||
set({ ...p, year: parseInt(y, 10), month: parseInt(m, 10), day: parseInt(d, 10) });
|
||||
}
|
||||
function onTime(p, set, e) {
|
||||
const v = e.target.value;
|
||||
if (!v) return set({ ...p, hour: null });
|
||||
const [h] = v.split(':');
|
||||
set({ ...p, hour: parseInt(h, 10) });
|
||||
}
|
||||
|
||||
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="이름">
|
||||
<input value={person.name || ''}
|
||||
onChange={(e) => onChange({ ...person, name: e.target.value })}
|
||||
placeholder="홍길동" style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="생년월일">
|
||||
<input type="date" value={dateValue(person)}
|
||||
onChange={(e) => onDate(person, onChange, e)} style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="시간">
|
||||
<input type="time" value={timeValue(person)}
|
||||
onChange={(e) => onTime(person, onChange, e)} style={inputStyle} />
|
||||
</InputRow>
|
||||
<InputRow label="성별">
|
||||
<select value={person.gender}
|
||||
onChange={(e) => onChange({ ...person, gender: e.target.value })}
|
||||
style={inputStyle}>
|
||||
<option value="male">남</option>
|
||||
<option value="female">여</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InputRow label="달력">
|
||||
<select value={person.calendar_type}
|
||||
onChange={(e) => onChange({ ...person, calendar_type: e.target.value })}
|
||||
style={inputStyle}>
|
||||
<option value="solar">양력</option>
|
||||
<option value="lunar">음력</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>
|
||||
);
|
||||
}
|
||||
499
src/pages/saju/views/saju.desktop.jsx
Normal file
499
src/pages/saju/views/saju.desktop.jsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DesktopHero from '../_shell/DesktopHero';
|
||||
import DesktopFooter from '../_shell/DesktopFooter';
|
||||
import PanelHeader from '../_shell/PanelHeader';
|
||||
import OrnamentBloom from '../_shell/OrnamentBloom';
|
||||
import { IconChevron, IconPaw } from '../_shell/Icons';
|
||||
import deriveTraits from '../_shell/helpers/deriveTraits';
|
||||
import daeunLabel from '../_shell/helpers/daeunLabel';
|
||||
import hexA from '../_shell/helpers/hexA';
|
||||
|
||||
const HANJA_TO_ID = { '木': 'wood', '火': 'fire', '土': 'earth', '金': 'metal', '水': 'water' };
|
||||
const ID_TO_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' };
|
||||
const ID_TO_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' };
|
||||
const ID_TO_COLOR = {
|
||||
wood: '#4E6B5C', fire: '#C04A4A', earth: '#A67B3F',
|
||||
metal: '#D4AF37', water: '#3A5A8C',
|
||||
};
|
||||
const STEM_EL = { '甲': 'wood', '乙': 'wood', '丙': 'fire', '丁': 'fire', '戊': 'earth', '己': 'earth', '庚': 'metal', '辛': 'metal', '壬': 'water', '癸': 'water' };
|
||||
const BRANCH_EL = { '子': 'water', '丑': 'earth', '寅': 'wood', '卯': 'wood', '辰': 'earth', '巳': 'fire', '午': 'fire', '未': 'earth', '申': 'metal', '酉': 'metal', '戌': 'earth', '亥': 'water' };
|
||||
|
||||
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
|
||||
|
||||
function elementsByEngId(scores = {}) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(scores || {})) {
|
||||
const id = HANJA_TO_ID[key] || key;
|
||||
if (ID_TO_KO[id]) out[id] = Number(value) || 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function maxElement(elementsObj) {
|
||||
return ['wood', 'fire', 'earth', 'metal', 'water']
|
||||
.map((id) => ({ id, value: Math.round(elementsObj[id] || 0), color: ID_TO_COLOR[id] }))
|
||||
.reduce((best, item) => (item.value > best.value ? item : best), { id: 'metal', value: 0, color: '#D4AF37' });
|
||||
}
|
||||
|
||||
function normalizePillar(pillar = {}, key) {
|
||||
const stem = pillar.stem || '-';
|
||||
const branch = pillar.branch || '-';
|
||||
return {
|
||||
id: key,
|
||||
label: PILLAR_LABELS[key],
|
||||
cheongan: {
|
||||
ch: stem,
|
||||
ko: pillar.stem_kr || '',
|
||||
mark: stem && STEM_EL[stem] ? `(${ID_TO_KO[STEM_EL[stem]]})` : '',
|
||||
color: ID_TO_COLOR[STEM_EL[stem]] || '#1F2A44',
|
||||
},
|
||||
jiji: {
|
||||
ch: branch,
|
||||
ko: pillar.branch_kr || '',
|
||||
mark: branch && BRANCH_EL[branch] ? `(${ID_TO_KO[BRANCH_EL[branch]]})` : '',
|
||||
color: ID_TO_COLOR[BRANCH_EL[branch]] || '#1F2A44',
|
||||
},
|
||||
sipsin: pillar.ten_god || '-',
|
||||
jijang: pillar.hidden_stems || pillar.fortune || '-',
|
||||
};
|
||||
}
|
||||
|
||||
function readingToDesktopData(reading) {
|
||||
const saju = reading?.saju_data || {};
|
||||
const elementsObj = elementsByEngId(reading?.analysis_data?.element_scores);
|
||||
const strongest = maxElement(elementsObj);
|
||||
const pillars = ['year', 'month', 'day', 'hour'].map((key) => normalizePillar(saju[key], key));
|
||||
const daeun = (reading?.daeun_data || []).map((item) => ({
|
||||
age: `${item.age}~${item.age + 9}세`,
|
||||
rawAge: item.age,
|
||||
gan: item.stem || item.gan || '-',
|
||||
label: daeunLabel(item.age),
|
||||
current: item.start_year <= new Date().getFullYear() && new Date().getFullYear() <= item.end_year,
|
||||
startYear: item.start_year,
|
||||
endYear: item.end_year,
|
||||
}));
|
||||
const fallbackDaeun = [0, 10, 20, 30, 40, 50, 60, 70].map((age, index) => ({
|
||||
age: `${age}~${age + 9}세`,
|
||||
rawAge: age,
|
||||
gan: ['戊', '丁', '丙', '乙', '甲', '癸', '壬', '辛'][index],
|
||||
label: daeunLabel(age),
|
||||
current: age === 30,
|
||||
}));
|
||||
|
||||
return {
|
||||
name: reading?.name || '백호',
|
||||
gender: reading?.gender === 'female' ? '여' : '남',
|
||||
birth: `${reading?.birth_year || '1990'}년 ${reading?.birth_month || '01'}월 ${reading?.birth_day || '01'}일 ${reading?.birth_hour ?? '10'}:00`,
|
||||
lunar: reading?.calendar_type === 'lunar' ? '음력 입력' : '양력 입력',
|
||||
birthPlace: reading?.birth_place || '서울특별시',
|
||||
ilgan: pillars[2]?.cheongan || { ch: '庚', color: '#3A5A8C' },
|
||||
pillars,
|
||||
elementsObj,
|
||||
ohaeng: ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({
|
||||
id, ko: ID_TO_KO[id], ch: ID_TO_CH[id],
|
||||
value: Math.round(elementsObj[id] || ({ wood: 20, fire: 35, earth: 25, metal: 55, water: 30 }[id])),
|
||||
color: ID_TO_COLOR[id],
|
||||
})),
|
||||
strongest,
|
||||
summary: reading?.interpretation_json?.summary || '의리가 강하고 책임감이 뛰어난 흐름입니다. 목표를 정하면 끝까지 해내는 추진력과 원칙을 중시하는 태도가 장점으로 드러납니다.',
|
||||
traits: deriveTraits(elementsObj, []),
|
||||
daeun: daeun.length ? daeun : fallbackDaeun,
|
||||
dayMasterStrength: reading?.analysis_data?.day_master_strength,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SajuDesktop({ reading }) {
|
||||
const navigate = useNavigate();
|
||||
const data = readingToDesktopData(reading);
|
||||
|
||||
return (
|
||||
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
|
||||
<DesktopHero
|
||||
title="사주풀이"
|
||||
subtitle="당신의 사주 구조와 흐름을 깊이 있게 풀어드립니다."
|
||||
accent="#D4AF37"
|
||||
bubble={<div>사주의 흐름을 읽고,<br />당신의 길을 밝혀드립니다.</div>}
|
||||
/>
|
||||
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '0 36px 32px' }}>
|
||||
<BasicInfoBar data={data} onEdit={() => navigate('/saju')} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 360px', gap: 18, marginTop: 20 }}>
|
||||
<SajuStructureCard data={data} />
|
||||
<OhaengCard data={data} />
|
||||
<HoryungInsightCard data={data} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, marginTop: 18 }}>
|
||||
<TraitDeskCard color="#A67B3F" iconName="will" title="핵심 성향" body={data.summary} />
|
||||
<TraitDeskCard color="#4E6B5C" iconName="adapt" title="강점" bullets={data.traits.slice(0, 4).map((t) => t.ko || t.label)} />
|
||||
<TraitDeskCard color="#C04A4A" iconName="challenge" title="주의할 점" bullets={['고집이 강할 수 있음', '완벽주의 경향', '휴식이 부족해지기 쉬움']} />
|
||||
<TraitDeskCard color="#3A5A8C" iconName="lead" title="직업운" body={`${ID_TO_KO[data.strongest.id]}(${ID_TO_CH[data.strongest.id]}) 기운을 중심으로 체계적이고 집중력이 필요한 분야에서 강점이 드러납니다.`} />
|
||||
<TraitDeskCard color="#D89098" iconName="heart" title="연애운" body="신뢰와 안정감을 중시하며 깊이 있는 관계를 만들어갑니다. 따뜻한 표현이 관계의 열쇠입니다." />
|
||||
</div>
|
||||
|
||||
<DaeunDeskCard data={data} />
|
||||
<ConsultCTA onClick={() => navigate('/saju/me')} />
|
||||
</div>
|
||||
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoBar({ data, onEdit }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '18px 24px', display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: 'rgba(212,175,55,0.10)', border: '1px solid rgba(212,175,55,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#B89530',
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#B89530" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="4" width="14" height="17" rx="2" />
|
||||
<path d="M8 8h8M8 12h8M8 16h5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em' }}>기본 정보</div>
|
||||
</div>
|
||||
<InfoCol label="이름" value={data.name} />
|
||||
<InfoCol label="성별" value={data.gender} />
|
||||
<InfoCol label="양력" value={data.birth} />
|
||||
<InfoCol label="음력" value={data.lunar} />
|
||||
<InfoCol label="출생지" value={data.birthPlace} />
|
||||
<InfoCol label="사주명리" value={<span>양 · 일간 <span className="font-title" style={{ color: data.ilgan.color, fontSize: 14 }}>{data.ilgan.ch}</span></span>} />
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={onEdit} style={{
|
||||
padding: '8px 18px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid rgba(31,42,68,0.2)',
|
||||
color: '#6B6B6B', fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap',
|
||||
}}>정보 수정</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCol({ label, value }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, color: '#9A968D', letterSpacing: '-0.01em', fontWeight: 700 }}>{label}</div>
|
||||
<div style={{ fontSize: 13, color: '#1F2A44', whiteSpace: 'nowrap', letterSpacing: '-0.01em' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SajuStructureCard({ data }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '20px 22px' }}>
|
||||
<PanelHeader title="사주 구조" />
|
||||
<table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle()} />
|
||||
{data.pillars.map((pillar) => (
|
||||
<th key={pillar.id} style={thStyle({ active: pillar.id === 'day' })}>
|
||||
{pillar.id === 'day' && (
|
||||
<div style={{
|
||||
fontSize: 9, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
|
||||
padding: '2px 8px', borderRadius: 99, display: 'inline-block', marginBottom: 4,
|
||||
}}>일간</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: '#1F2A44', fontWeight: 700 }}>{pillar.label}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Row label="천간" cells={data.pillars.map((pillar) => pillar.cheongan)} day />
|
||||
<Row label="지지" cells={data.pillars.map((pillar) => pillar.jiji)} day />
|
||||
<RowText label="십신" cells={data.pillars.map((pillar) => pillar.sipsin)} />
|
||||
<RowText label="지장간" cells={data.pillars.map((pillar) => pillar.jijang)} mono />
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{
|
||||
marginTop: 12, padding: '10px 14px',
|
||||
background: 'rgba(106,76,124,0.06)', borderRadius: 8,
|
||||
border: '1px dashed rgba(106,76,124,0.25)',
|
||||
fontSize: 11.5, color: '#6B6B6B', lineHeight: 1.6,
|
||||
}}>
|
||||
※ 일간(나)을 중심으로 사주의 흐름과 균형을 해석합니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = ({ active = false } = {}) => ({
|
||||
padding: '8px 4px 12px',
|
||||
textAlign: 'center',
|
||||
borderBottom: '1px solid rgba(31,42,68,0.08)',
|
||||
background: active ? 'rgba(106,76,124,0.06)' : 'transparent',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
function Row({ label, cells, day }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontSize: 11, color: '#9A968D', fontWeight: 700, padding: '14px 8px', textAlign: 'center' }}>{label}</td>
|
||||
{cells.map((cell, index) => {
|
||||
const isDay = index === 2 && day;
|
||||
return (
|
||||
<td key={`${cell.ch}-${index}`} style={{
|
||||
padding: '10px 4px', textAlign: 'center',
|
||||
background: isDay ? 'rgba(106,76,124,0.06)' : 'transparent',
|
||||
borderTop: '1px solid rgba(31,42,68,0.04)',
|
||||
}}>
|
||||
<div className="font-title" style={{ fontSize: 28, color: cell.color, lineHeight: 1, letterSpacing: 0 }}>{cell.ch}</div>
|
||||
<div style={{ fontSize: 9.5, color: hexA(cell.color, 0.9), fontWeight: 700, marginTop: 3, letterSpacing: '-0.02em' }}>
|
||||
{cell.ko} <span style={{ color: '#9A968D', fontWeight: 500 }}>{cell.mark}</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function RowText({ label, cells, mono }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontSize: 11, color: '#9A968D', fontWeight: 700, padding: '10px 8px', textAlign: 'center' }}>{label}</td>
|
||||
{cells.map((cell, index) => (
|
||||
<td key={`${cell}-${index}`} style={{
|
||||
padding: '8px 4px', textAlign: 'center',
|
||||
background: index === 2 ? 'rgba(106,76,124,0.06)' : 'transparent',
|
||||
borderTop: '1px solid rgba(31,42,68,0.04)',
|
||||
fontFamily: mono ? 'var(--font-title)' : 'inherit',
|
||||
fontSize: mono ? 13 : 12,
|
||||
color: '#1F2A44',
|
||||
letterSpacing: mono ? '0.1em' : '-0.01em',
|
||||
}}>{String(cell || '-')}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function OhaengCard({ data }) {
|
||||
const strongest = maxElement(data.elementsObj);
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '20px 22px' }}>
|
||||
<PanelHeader title="오행 분석" />
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-around', height: 160, gap: 8, padding: '0 8px' }}>
|
||||
{data.ohaeng.map((element) => {
|
||||
const height = Math.min(100, Math.max(8, (element.value / 60) * 100));
|
||||
return (
|
||||
<div key={element.id} 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: 11, color: element.color, fontWeight: 700, textAlign: 'center', marginBottom: 4 }}>{element.value}%</div>
|
||||
<div style={{
|
||||
width: 28, margin: '0 auto', height: `${height}%`, minHeight: 6,
|
||||
background: `linear-gradient(180deg, ${hexA(element.color, 0.85)}, ${element.color})`,
|
||||
borderRadius: '6px 6px 2px 2px',
|
||||
boxShadow: `0 -2px 8px ${hexA(element.color, 0.3)}, inset 0 1px 0 rgba(255,255,255,0.3)`,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#1F2A44', fontWeight: 700, display: 'flex', alignItems: 'baseline', gap: 3 }}>
|
||||
{element.ko}<span style={{ fontSize: 10, color: '#9A968D' }}>({element.ch})</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 16, padding: '12px 14px',
|
||||
background: hexA(strongest.color, 0.08), borderRadius: 8,
|
||||
border: `1px solid ${hexA(strongest.color, 0.2)}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: strongest.color, marginBottom: 4 }}>
|
||||
{ID_TO_KO[strongest.id]}({ID_TO_CH[strongest.id]})의 기운이 강한 사주입니다.
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.6 }}>
|
||||
강한 기운을 바탕으로 장점을 살리고 부족한 기운은 생활 습관과 관계에서 보완해 보세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoryungInsightCard({ data }) {
|
||||
const strongest = maxElement(data.elementsObj);
|
||||
const items = [
|
||||
{ title: `일간이 ${data.ilgan.ch}이시네요.`, desc: '단단한 중심과 자기 기준을 갖고 흐름을 읽는 힘이 있습니다.' },
|
||||
{ title: `${ID_TO_KO[strongest.id]}(${ID_TO_CH[strongest.id]})의 기운이 두드러져요.`, desc: '해당 기운의 장점을 생활과 일의 방향으로 살려보세요.' },
|
||||
{ title: '균형을 보완하면 더욱 좋아요.', desc: '강한 기운만 밀어붙이기보다 부족한 기운을 의식하면 흐름이 부드러워집니다.' },
|
||||
{ title: '지금의 선택이 미래의 나를 만듭니다.', desc: '작은 실천을 꾸준히 쌓는 시기로 삼아보세요.' },
|
||||
];
|
||||
return (
|
||||
<div className="k-frame dark" style={{ padding: '22px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, color: '#E8C76B' }}>
|
||||
<svg width="32" height="6" viewBox="0 0 32 6"><path d="M0 3 L28 3" stroke="#E8C76B" strokeWidth="1" /><circle cx="30" cy="3" r="1.5" fill="#E8C76B" /></svg>
|
||||
<h3 className="font-title" style={{ margin: 0, fontSize: 17, color: '#E8C76B', letterSpacing: '-0.01em' }}>호령이의 해설</h3>
|
||||
<svg width="32" height="6" viewBox="0 0 32 6"><circle cx="2" cy="3" r="1.5" fill="#E8C76B" /><path d="M4 3 L32 3" stroke="#E8C76B" strokeWidth="1" /></svg>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 4 }}>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.title} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
background: 'rgba(212,175,55,0.12)', border: '1px solid rgba(212,175,55,0.35)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
color: '#E8C76B', fontSize: 13, fontWeight: 800,
|
||||
}}>{index + 1}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#F7F2E8', marginBottom: 3 }}>{item.title}</div>
|
||||
<div style={{ fontSize: 11.5, color: '#D9D2C0', lineHeight: 1.55 }}>{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 6, padding: '14px 16px', borderRadius: 10,
|
||||
background: 'rgba(212,175,55,0.08)', border: '1px solid rgba(212,175,55,0.3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div className="font-title" style={{ fontSize: 14, color: '#E8C76B', lineHeight: 1.5 }}>
|
||||
지금의 선택이<br />미래의 나를 만듭니다.
|
||||
<span style={{ marginLeft: 4, opacity: 0.7 }}><IconPaw size={11} color="#E8C76B" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitDeskCard({ color, iconName, title, body, bullets }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '18px 18px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<TraitIcon name={iconName} color={color} size={16} />
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 15, color: '#1F2A44', letterSpacing: '-0.02em' }}>{title}</div>
|
||||
</div>
|
||||
{body && <div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.65 }}>{body}</div>}
|
||||
{bullets && (
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{bullets.map((item) => (
|
||||
<li key={item} style={{ fontSize: 12, color: '#1F2A44', display: 'flex', gap: 6 }}>
|
||||
<span style={{ color, flexShrink: 0 }}>·</span> {item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitIcon({ name, color, size }) {
|
||||
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: color, strokeWidth: '1.7', strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||
if (name === 'heart') return <svg {...common}><path d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.5-7 10-7 10z" /></svg>;
|
||||
if (name === 'challenge') return <svg {...common}><path d="M12 3l10 17H2z" /><path d="M12 10v5M12 18v.5" /></svg>;
|
||||
if (name === 'lead') return <svg {...common}><path d="M4 16c4-6 8-6 16 0" /><path d="M8 12l4-4 4 4" /></svg>;
|
||||
if (name === 'adapt') return <svg {...common}><path d="M4 12c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" /><circle cx="12" cy="12" r="2" /></svg>;
|
||||
return <svg {...common}><path d="M12 3v18M5 9l7-6 7 6M6 17h12" /></svg>;
|
||||
}
|
||||
|
||||
function DaeunDeskCard({ data }) {
|
||||
const current = data.daeun.find((item) => item.current) || data.daeun[0];
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '22px 24px', marginTop: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
<OrnamentBloom size={20} color="#D4AF37" />
|
||||
<h3 className="font-title" style={{ margin: 0, fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em' }}>대운 흐름</h3>
|
||||
<span style={{ fontSize: 12, color: '#9A968D' }}>10년 단위 운의 흐름을 살펴보세요.</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.2fr', gap: 20, alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{data.daeun.map((item, index) => (
|
||||
<React.Fragment key={`${item.age}-${index}`}>
|
||||
<DaeunNodeDesk {...item} />
|
||||
{index < data.daeun.length - 1 && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: 12 }}>
|
||||
<IconChevron dir="right" size={12} color={item.current || data.daeun[index + 1].current ? '#6A4C7C' : '#D4AF37'} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(212,175,55,0.06)', borderRadius: 10,
|
||||
border: '1px dashed rgba(212,175,55,0.4)', padding: '14px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
|
||||
padding: '2px 8px', borderRadius: 99,
|
||||
}}>현재</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>대운 해설 ({current?.age})</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.7 }}>
|
||||
자기 확장과 기반을 다지는 시기입니다.<br />
|
||||
꾸준한 노력과 인내가 결실을 맺고, 커리어와 재정적 성장이 기대됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DaeunNodeDesk({ age, gan, label, current }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, position: 'relative', minWidth: 64 }}>
|
||||
{current && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -8, left: '50%', transform: 'translateX(-50%)',
|
||||
fontSize: 9, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
|
||||
padding: '2px 8px', borderRadius: 99, zIndex: 1, whiteSpace: 'nowrap',
|
||||
}}>현재</div>
|
||||
)}
|
||||
<div style={{ fontSize: 10, color: '#9A968D', marginTop: current ? 12 : 4, fontWeight: 700, whiteSpace: 'nowrap' }}>{age}</div>
|
||||
<div style={{
|
||||
width: 48, height: 58, borderRadius: '50% 50% 40% 40%',
|
||||
background: current ? '#1F2A44' : '#FBF7EF',
|
||||
border: current ? '2px solid #D4AF37' : '1px solid rgba(31,42,68,0.12)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: current ? '0 4px 14px rgba(31,42,68,0.3)' : 'none',
|
||||
}}>
|
||||
<span className="font-title" style={{ fontSize: 22, color: current ? '#E8C76B' : '#1F2A44' }}>{gan}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: current ? '#6A4C7C' : '#6B6B6B', fontWeight: current ? 700 : 500 }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsultCTA({ onClick }) {
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 18, padding: '28px 32px',
|
||||
background: '#1F2A44', color: '#F7F2E8',
|
||||
borderRadius: 14, border: '1px solid rgba(212,175,55,0.4)',
|
||||
display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 24,
|
||||
boxShadow: '0 12px 40px rgba(31,42,68,0.18)',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, color: '#E8C76B' }}>
|
||||
<OrnamentBloom size={16} color="#E8C76B" />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.1em' }}>1:1 PERSONAL CONSULT</span>
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 22, color: '#F7F2E8', letterSpacing: '-0.02em' }}>
|
||||
더 깊은 해석이 필요하신가요?
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: '#D9D2C0' }}>
|
||||
개인 맞춤 상담을 통해 당신의 사주를 더 깊이 이해하고 명확한 방향을 찾아보세요.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClick} style={{
|
||||
padding: '14px 24px', borderRadius: 99,
|
||||
background: '#E8C76B', color: '#1F2A44',
|
||||
border: 'none', fontSize: 14, fontWeight: 800,
|
||||
boxShadow: '0 6px 18px rgba(232,199,107,0.4), inset 0 1px 0 rgba(255,255,255,0.4)',
|
||||
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
1:1 상담 신청하기 <IconPaw size={14} color="#1F2A44" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
src/pages/saju/views/saju.mobile.jsx
Normal file
424
src/pages/saju/views/saju.mobile.jsx
Normal file
@@ -0,0 +1,424 @@
|
||||
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 hexA from '../_shell/helpers/hexA';
|
||||
|
||||
// 한자 element key → english id (deriveTraits 입력 표준화)
|
||||
const HANJA_TO_ID = { '木': 'wood', '火': 'fire', '土': 'earth', '金': 'metal', '水': 'water' };
|
||||
const ID_TO_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' };
|
||||
const ID_TO_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' };
|
||||
const ID_TO_COLOR = {
|
||||
wood: '#4E6B5C', fire: '#C04A4A', earth: '#A67B3F',
|
||||
metal: '#D4AF37', water: '#3A5A8C',
|
||||
};
|
||||
|
||||
function elementsByEngId(scores) {
|
||||
if (!scores) return {};
|
||||
const out = {};
|
||||
for (const [hanja, val] of Object.entries(scores)) {
|
||||
const id = HANJA_TO_ID[hanja];
|
||||
if (id) out[id] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pillarStemColor(saju, pillarKey) {
|
||||
const stem = saju?.[pillarKey]?.stem;
|
||||
// 천간 → 오행 매핑 (간략 — 핵심 색만)
|
||||
const STEM_EL = { '甲':'wood','乙':'wood','丙':'fire','丁':'fire','戊':'earth','己':'earth','庚':'metal','辛':'metal','壬':'water','癸':'water' };
|
||||
return ID_TO_COLOR[STEM_EL[stem]] || '#1F2A44';
|
||||
}
|
||||
|
||||
function pillarBranchColor(saju, pillarKey) {
|
||||
const branch = saju?.[pillarKey]?.branch;
|
||||
const BRANCH_EL = { '子':'water','丑':'earth','寅':'wood','卯':'wood','辰':'earth','巳':'fire','午':'fire','未':'earth','申':'metal','酉':'metal','戌':'earth','亥':'water' };
|
||||
return ID_TO_COLOR[BRANCH_EL[branch]] || '#1F2A44';
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
['basic', '기본정보'],
|
||||
['chart', '사주명식'],
|
||||
['flow', '운세흐름'],
|
||||
['traits', '성향분석'],
|
||||
];
|
||||
|
||||
export default function SajuMobile({ reading }) {
|
||||
const [tab, setTab] = useState('basic');
|
||||
const navigate = useNavigate();
|
||||
const elementsObj = elementsByEngId(reading?.analysis_data?.element_scores);
|
||||
const traits = deriveTraits(elementsObj, []);
|
||||
|
||||
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} elementsObj={elementsObj} />}
|
||||
{tab === 'flow' && <FlowTab reading={reading} />}
|
||||
{tab === 'traits' && <TraitsTab traits={traits} onToday={() => navigate(`/saju/today?rid=${reading?.id || ''}`)} />}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicTab({ reading, traits, onResult }) {
|
||||
const r = reading || {};
|
||||
const rows = [
|
||||
['생년월일', `${r.birth_year}년 ${r.birth_month}월 ${r.birth_day}일 (${r.calendar_type === 'lunar' ? '음력' : '양력'})`],
|
||||
['시간', r.birth_hour != null ? `${r.birth_hour}시` : '시간 미상'],
|
||||
['성별', r.gender === 'female' ? '여' : '남'],
|
||||
['사주', [r.saju_data?.year, r.saju_data?.month, r.saju_data?.day, r.saju_data?.hour].filter(Boolean).map((p) => `${p.stem}${p.branch}`).join(' ') || '-'],
|
||||
];
|
||||
const summary = reading?.interpretation_json?.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: 80, 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 }) {
|
||||
// color는 'var(--el-fire)' 같은 CSS var. swatch에 직접 사용.
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, padding: '10px 4px 8px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: '50%',
|
||||
background: 'rgba(106,76,124,0.06)',
|
||||
border: `1px solid ${color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color, fontSize: 18, fontWeight: 800,
|
||||
}}>●</div>
|
||||
<span style={{ fontSize: 11, color: '#1F2A44', fontWeight: 700, letterSpacing: '-0.02em' }}>{ko}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartTab({ reading, elementsObj }) {
|
||||
const saju = reading?.saju_data || {};
|
||||
const ohaengArr = ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({
|
||||
id, ko: ID_TO_KO[id], ch: ID_TO_CH[id],
|
||||
value: Math.round(elementsObj?.[id] || 0),
|
||||
color: ID_TO_COLOR[id],
|
||||
}));
|
||||
const strongest = ohaengArr.reduce((a, b) => (a.value > b.value ? a : b), { value: 0 });
|
||||
const dms = reading?.analysis_data?.day_master_strength;
|
||||
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 }}>
|
||||
{['year', 'month', 'day', 'hour'].map((pk) => (
|
||||
<PillarColumn key={pk} pillarKey={pk}
|
||||
pillar={saju[pk]}
|
||||
stemColor={pillarStemColor(saju, pk)}
|
||||
branchColor={pillarBranchColor(saju, pk)}
|
||||
/>
|
||||
))}
|
||||
</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 ${hexA(strongest.color, 0.25)}`,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: strongest.color, marginBottom: 4 }}>
|
||||
{strongest.ko}({strongest.ch})의 기운이 강한 사주입니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dms && (
|
||||
<div style={{
|
||||
marginTop: 10, padding: '10px 12px',
|
||||
background: 'rgba(106,76,124,0.06)', borderRadius: 8,
|
||||
border: '1px dashed rgba(106,76,124,0.25)',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#6A4C7C', marginBottom: 4 }}>
|
||||
일간 강도: {dms.result} · {dms.score}점
|
||||
</div>
|
||||
{dms.reasons && dms.reasons.length > 0 && (
|
||||
<div style={{ fontSize: 11, color: '#6B6B6B', lineHeight: 1.55 }}>
|
||||
{dms.reasons.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
|
||||
|
||||
function PillarColumn({ pillarKey, pillar, stemColor, branchColor }) {
|
||||
const isDay = pillarKey === 'day';
|
||||
if (!pillar) {
|
||||
return (
|
||||
<div style={{ padding: '8px 4px 10px', textAlign: 'center', color: '#9A968D', fontSize: 11 }}>
|
||||
{PILLAR_LABELS[pillarKey]}<br />-
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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: 6, 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 }}>{PILLAR_LABELS[pillarKey]}</div>
|
||||
<CharBox char={pillar.stem} sub={pillar.stem_kr} color={stemColor} />
|
||||
<CharBox char={pillar.branch} sub={pillar.branch_kr} color={branchColor} />
|
||||
<div style={{ width: '100%', height: 1, background: 'rgba(31,42,68,0.08)' }} />
|
||||
<div style={{ fontSize: 10, color: '#6B6B6B' }}>{pillar.ten_god || '-'}</div>
|
||||
<div style={{ fontSize: 10, color: '#9A968D' }}>{pillar.fortune || ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CharBox({ char, sub, 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 }}>{sub || ''}</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_data || [];
|
||||
const currentYear = new Date().getFullYear();
|
||||
const enriched = daeun.map((d) => ({
|
||||
...d,
|
||||
label: daeunLabel(d.age),
|
||||
current: d.start_year <= currentYear && currentYear <= d.end_year,
|
||||
}));
|
||||
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}~{current.age + 9}세</div>
|
||||
<div className="font-title" style={{ marginTop: 8, fontSize: 18, color: '#E8C76B' }}>
|
||||
{current.stem}{current.branch} · {current.label}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: '#D9D2C0', lineHeight: 1.7 }}>
|
||||
{current.start_year}년 ~ {current.end_year}년 — 이 시기는 {current.label} 단계로,
|
||||
10년 간의 운기 흐름을 차분히 살펴보세요.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DaeunNode({ age, stem, 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' }}>{stem}</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>
|
||||
);
|
||||
}
|
||||
245
src/pages/saju/views/today.desktop.jsx
Normal file
245
src/pages/saju/views/today.desktop.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DesktopFooter from '../_shell/DesktopFooter';
|
||||
import Mascot from '../_shell/Mascot';
|
||||
import PanelHeader from '../_shell/PanelHeader';
|
||||
import {
|
||||
IconClock, IconHeart, IconMoney, IconPaw, IconSparkle, IconStar, IconSun,
|
||||
} from '../_shell/Icons';
|
||||
import hexA from '../_shell/helpers/hexA';
|
||||
|
||||
const SCORE_LABELS = [
|
||||
{ key: 'wealth', label: '재물운', color: '#D4AF37', icon: IconMoney, desc: '안정적인 흐름, 수입에 긍정적인 변화가 있어요.' },
|
||||
{ key: 'romance', label: '연애운', color: '#D89098', icon: IconHeart, desc: '진심이 통하는 하루, 관계가 한층 가까워져요.' },
|
||||
{ key: 'social', label: '건강운', color: '#4E6B5C', icon: LeafIcon, desc: '컨디션이 무난해요. 규칙적인 관리가 필요해요.' },
|
||||
{ key: 'career', label: '직장운', color: '#3A5A8C', icon: BriefcaseIcon, desc: '업무 성과가 좋아요. 기획력이 빛을 발합니다.' },
|
||||
];
|
||||
|
||||
export default function TodayDesktop({ reading }) {
|
||||
const navigate = useNavigate();
|
||||
const scores = reading?.fortune_scores || {};
|
||||
const lucky = reading?.lucky || {};
|
||||
const overall = Math.round(scores.overall || 78);
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 92 }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '0 36px 0' }}>
|
||||
<div style={{ fontSize: 12, color: '#9A968D', marginBottom: 16, letterSpacing: '-0.01em' }}>
|
||||
홈 › <span style={{ color: '#1F2A44', fontWeight: 700 }}>오늘의 운세</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '320px 1fr', gap: 24, alignItems: 'flex-start' }}>
|
||||
<aside className="k-frame" style={{ padding: 24, textAlign: 'center', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.10)',
|
||||
borderRadius: 18, padding: '14px 16px',
|
||||
fontSize: 13, color: '#1F2A44', lineHeight: 1.75, letterSpacing: '-0.01em',
|
||||
}}>
|
||||
안녕하세요!<br />오늘의 운세를 정성껏<br />전해드릴게요.
|
||||
<span style={{ marginLeft: 4, color: '#B89530', opacity: 0.7 }}><IconPaw size={11} /></span>
|
||||
</div>
|
||||
<Mascot variant="full" size={260} style={{ margin: '10px auto 0' }} />
|
||||
<div className="k-frame dark" style={{ marginTop: 8, padding: '17px 14px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#E8C76B', fontWeight: 700, letterSpacing: '0.16em', marginBottom: 7 }}>오늘의 한마디</div>
|
||||
<div className="font-title" style={{ fontSize: 17, color: '#F7F2E8', lineHeight: 1.65 }}>
|
||||
흐름을 읽는 자가<br />기회를 얻습니다.
|
||||
</div>
|
||||
<div style={{ marginTop: 7, fontSize: 12, color: '#D9D2C0', letterSpacing: '-0.01em' }}>
|
||||
작은 선택이 큰 변화를 만듭니다.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div className="k-frame" style={{ padding: '0', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '32px 40px',
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(31,42,68,0.96) 0%, rgba(31,42,68,0.78) 34%, rgba(251,247,239,0.92) 72%), url(/images/saju/horyung/background.png) center / cover',
|
||||
minHeight: 150,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div>
|
||||
<h1 className="font-title" style={{ margin: 0, fontSize: 46, color: '#F7F2E8', letterSpacing: '-0.035em' }}>오늘의 운세</h1>
|
||||
<div style={{ marginTop: 8, fontSize: 15, color: '#F1E8D6', letterSpacing: '-0.01em' }}>오늘의 흐름을 한눈에 확인해 보세요.</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 15, color: '#1F2A44', fontWeight: 800 }}>{today}</div>
|
||||
<button style={{
|
||||
marginTop: 12, padding: '9px 16px', borderRadius: 999,
|
||||
border: '1px solid rgba(166,123,63,0.35)', background: 'rgba(251,247,239,0.7)',
|
||||
color: '#6B4423', fontSize: 12, fontWeight: 700,
|
||||
}}>간지 정보 보기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="k-frame" style={{ padding: '24px 28px', display: 'grid', gridTemplateColumns: '320px 1fr', gap: 28, alignItems: 'center' }}>
|
||||
<div style={{
|
||||
textAlign: 'center', padding: '24px 0', borderRadius: 14,
|
||||
background: 'rgba(212,175,55,0.06)', border: '1px dashed rgba(212,175,55,0.4)',
|
||||
}}>
|
||||
<div style={{ fontSize: 15, color: '#1F2A44', fontWeight: 800, marginBottom: 8 }}>오늘의 종합운</div>
|
||||
<div className="font-title" style={{ fontSize: 72, color: '#1F2A44', lineHeight: 1, letterSpacing: '-0.05em' }}>
|
||||
{overall}<span style={{ fontSize: 28, color: '#1F2A44', fontWeight: 400 }}>/100</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center', gap: 3 }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => <IconStar key={i} filled={i <= Math.round(overall / 20)} size={18} color="#D4AF37" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-title" style={{ fontSize: 26, color: '#B89530', letterSpacing: '-0.03em' }}>
|
||||
새로운 기회가 찾아오는 날입니다.
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 14, color: '#3E4456', lineHeight: 1.8, letterSpacing: '-0.01em' }}>
|
||||
작은 실천이 큰 변화를 만듭니다. 주변의 조언에 귀 기울여 보세요.<br />
|
||||
따뜻한 말 한마디가 당신의 하루를 빛나게 할 것입니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||
{SCORE_LABELS.map((item) => (
|
||||
<FortuneCard key={item.key} {...item} value={Math.round(scores[item.key] || (item.key === 'career' ? 82 : item.key === 'wealth' ? 80 : item.key === 'romance' ? 70 : 75))} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
||||
<SmallCard color="#D4AF37" icon={IconSun} title="행운의 색" sub="오늘의 기운을 높여주는 색상">
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
{(Array.isArray(lucky.color) ? lucky.color : ['#1F2A44', '#E8C76B', '#6B4423', '#D89098', '#F7F2E8']).map((color) => (
|
||||
<div key={color} style={{ width: 26, height: 26, borderRadius: '50%', background: color, border: '1px solid rgba(31,42,68,0.15)' }} />
|
||||
))}
|
||||
</div>
|
||||
</SmallCard>
|
||||
<SmallCard color="#A67B3F" icon={IconClock} title="행운의 시간" sub="기운이 상승하는 시간대">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 10, padding: '9px 12px', background: 'rgba(166,123,63,0.08)', borderRadius: 999, border: '1px dashed rgba(166,123,63,0.35)' }}>
|
||||
<IconClock size={14} stroke="#A67B3F" />
|
||||
<span style={{ fontSize: 13, color: '#1F2A44', fontWeight: 700 }}>{lucky.time || '오전 10시 ~ 12시'}</span>
|
||||
</div>
|
||||
</SmallCard>
|
||||
<SmallCard color="#4E6B5C" icon={LeafIcon} title="오늘의 조언" sub="오늘 마음에 새기면 좋은 말">
|
||||
<div style={{ marginTop: 10, fontSize: 13, color: '#1F2A44', lineHeight: 1.6 }}>
|
||||
기회는 준비된 마음을<br />늘 찾아옵니다.
|
||||
</div>
|
||||
</SmallCard>
|
||||
<SmallCard color="#C04A4A" icon={WarnIcon} title="주의할 점" sub="조심하면 좋은 부분">
|
||||
<div style={{ marginTop: 10, fontSize: 13, color: '#1F2A44', lineHeight: 1.6 }}>
|
||||
충동적인 결정은 피하고,<br />여유를 가지세요.
|
||||
</div>
|
||||
</SmallCard>
|
||||
</div>
|
||||
|
||||
<div className="k-frame" style={{
|
||||
padding: '24px 30px',
|
||||
display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 14, alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<div className="font-title" style={{ fontSize: 22, color: '#1F2A44', letterSpacing: '-0.03em' }}>더 깊이 알고 싶으신가요?</div>
|
||||
<div style={{ fontSize: 13, color: '#6B6B6B', marginTop: 4 }}>
|
||||
오늘의 운세를 넘어, 당신만을 위한 정밀한 사주 분석으로 인생의 방향을 찾아드려요.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => navigate(reading?.id ? `/saju/result?rid=${reading.id}` : '/saju/result')} style={buttonPrimary()}>
|
||||
사주풀이 시작하기 <IconPaw size={13} color="#E8C76B" />
|
||||
</button>
|
||||
<button style={buttonGhost()}>
|
||||
<IconSparkle size={13} color="#B89530" /> AI 맞춤 인사이트 보기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FortuneCard({ label, value, icon: IconComponent, desc, color }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '20px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: '50%',
|
||||
background: hexA(color, 0.12), border: `1px solid ${hexA(color, 0.35)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{React.createElement(IconComponent, { size: 20, stroke: color })}
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 19, color: '#1F2A44', letterSpacing: '-0.03em' }}>{label}</div>
|
||||
</div>
|
||||
<div className="font-title" style={{ fontSize: 30, color: '#1F2A44', lineHeight: 1 }}>
|
||||
{value}<span style={{ fontSize: 16, color: '#1F2A44', fontWeight: 400 }}>/100</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 12.5, color: '#6B6B6B', lineHeight: 1.55 }}>{desc}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallCard({ color, icon: IconComponent, title, sub, children }) {
|
||||
return (
|
||||
<div className="k-frame" style={{ padding: '18px 18px' }}>
|
||||
<PanelHeader title={title} color="#1F2A44" accent={color} icon={(
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: '50%',
|
||||
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{React.createElement(IconComponent, { size: 15, stroke: color })}
|
||||
</div>
|
||||
)} />
|
||||
<div style={{ marginTop: -10, fontSize: 11, color: '#9A968D' }}>{sub}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonPrimary() {
|
||||
return {
|
||||
padding: '14px 24px', borderRadius: 999, background: '#1F2A44', color: '#F7F2E8',
|
||||
border: '1px solid rgba(212,175,55,0.4)', fontSize: 14, fontWeight: 800,
|
||||
boxShadow: '0 4px 14px rgba(31,42,68,0.25), inset 0 1px 0 rgba(212,175,55,0.3)',
|
||||
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
|
||||
};
|
||||
}
|
||||
|
||||
function buttonGhost() {
|
||||
return {
|
||||
padding: '14px 24px', borderRadius: 999, background: 'transparent', color: '#1F2A44',
|
||||
border: '1px solid rgba(31,42,68,0.25)', fontSize: 14, fontWeight: 800,
|
||||
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
|
||||
};
|
||||
}
|
||||
|
||||
function LeafIcon({ size = 16, stroke = '#4E6B5C' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20V10" />
|
||||
<path d="M12 10c-4 0-7-2-8-6 5 0 8 2 8 6z" />
|
||||
<path d="M12 13c4 0 7-2 8-6-5 0-8 2-8 6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefcaseIcon({ size = 16, stroke = '#3A5A8C' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WarnIcon({ size = 14, stroke = '#C04A4A' }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3l10 17H2z" />
|
||||
<path d="M12 10v5M12 18v.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
145
src/pages/saju/views/today.mobile.jsx
Normal file
145
src/pages/saju/views/today.mobile.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
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 } from '../_shell/Icons';
|
||||
|
||||
const SCORE_LABELS = {
|
||||
wealth: { ko: '재물운', icon: '財' },
|
||||
romance: { ko: '연애운', icon: '愛' },
|
||||
social: { ko: '인간관계', icon: '人' },
|
||||
career: { ko: '직장운', icon: '職' },
|
||||
};
|
||||
|
||||
export default function TodayMobile({ reading }) {
|
||||
const navigate = useNavigate();
|
||||
const scores = reading?.fortune_scores || {};
|
||||
const lucky = reading?.lucky || {};
|
||||
const signs = lucky.good_signs || [];
|
||||
const warnings = lucky.warnings || [];
|
||||
const overall = Math.round(scores.overall || 0);
|
||||
const todayStr = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
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={todayStr} />
|
||||
</div>
|
||||
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<MascotBubble tone="ivory"
|
||||
text={'오늘 하루도\n좋은 흐름이 있어요.'}
|
||||
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={Array.isArray(lucky.color) ? lucky.color.join(', ') : 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(reading?.id ? `/saju/result?rid=${reading.id}` : '/saju/result')}>
|
||||
내 사주 자세히 보기 <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>
|
||||
);
|
||||
}
|
||||
@@ -3016,3 +3016,337 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Holdings Intelligence Tab
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
.hi-panel {
|
||||
/* reuses stock-panel--wide layout */
|
||||
}
|
||||
|
||||
/* ── 포트 건강 요약 줄 ── */
|
||||
.hi-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hi-health__sep {
|
||||
margin: 0 8px;
|
||||
color: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
.hi-health__pnl {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hi-health__pnl.is-up { color: #22c55e; }
|
||||
.hi-health__pnl.is-down { color: #ef4444; }
|
||||
|
||||
/* ── 분석 기준일 ── */
|
||||
.hi-date {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* ── 카드 그리드 ── */
|
||||
.hi-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── 개별 카드 ── */
|
||||
.hi-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hi-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hi-action-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hi-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.hi-card__ticker {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hi-card__pnl {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hi-card__pnl.is-up { color: #22c55e; }
|
||||
.hi-card__pnl.is-down { color: #ef4444; }
|
||||
|
||||
.hi-card__close {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.hi-card__reasons {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 기술강도 미니 바 ── */
|
||||
.hi-card__score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.hi-card__score strong {
|
||||
color: #93c5fd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hi-score-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.hi-score-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: var(--score, 0%);
|
||||
background: #93c5fd;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* ── 이슈 목록 ── */
|
||||
.hi-card__issues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hi-issue {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hi-issue__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.hi-empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.hi-empty__icon {
|
||||
font-size: 36px;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hi-empty__sub {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* ── 면책 고지 ── */
|
||||
.hi-disclaimer {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
/* ── 탭 버튼 (holdings intel) ── */
|
||||
.stock-main-tab--holdings-intel {
|
||||
/* reuses stock-main-tab base styles */
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hi-card__head {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hi-health {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hi-score-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */
|
||||
.wl-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wl-form__input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wl-form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.wl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.wl-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wl-row__name { font-size: 14px; }
|
||||
.wl-row__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; }
|
||||
.wl-del {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.wl-period-toggle { display: flex; gap: 4px; }
|
||||
.wl-period {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wl-period.is-active {
|
||||
color: var(--accent-stock);
|
||||
border-color: var(--accent-stock);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.wl-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wl-alert {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wl-alert__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-kind-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wl-alert__name { font-size: 14px; }
|
||||
.wl-alert__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.wl-alert__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwipeableView from '../../components/SwipeableView';
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
|
||||
} from './stockUtils';
|
||||
|
||||
/* ── hooks ──────────────────────────────────────────────────────── */
|
||||
@@ -17,11 +17,14 @@ import useAssetHistory from './hooks/useAssetHistory';
|
||||
import useMarketContext from './hooks/useMarketContext';
|
||||
import useReportData from './hooks/useReportData';
|
||||
import useAdvisor from './hooks/useAdvisor';
|
||||
import useWatchlist from './hooks/useWatchlist';
|
||||
|
||||
/* ── tab components ─────────────────────────────────────────────── */
|
||||
import PortfolioTab from './components/PortfolioTab';
|
||||
import ReportTab from './components/ReportTab';
|
||||
import AdvisorTab from './components/AdvisorTab';
|
||||
import HoldingsIntelTab from './components/HoldingsIntelTab';
|
||||
import WatchlistTab from './components/WatchlistTab';
|
||||
import SellHistoryDrawer from './components/SellHistoryDrawer';
|
||||
|
||||
/* ── component ───────────────────────────────────────────────────── */
|
||||
@@ -30,8 +33,8 @@ const StockTrade = () => {
|
||||
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저'];
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
|
||||
const tabIndex = TAB_ORDER.indexOf(activeTab);
|
||||
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -61,6 +64,7 @@ const StockTrade = () => {
|
||||
totalAssets: pf.totalAssets,
|
||||
marketCtx,
|
||||
});
|
||||
const wl = useWatchlist();
|
||||
|
||||
/* ── sell history filter derived ─────────────────────────────── */
|
||||
const sellHistoryBrokers = useMemo(() => {
|
||||
@@ -166,7 +170,11 @@ const StockTrade = () => {
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: <AdvisorTab pf={pf} advisor={advisor} />,
|
||||
: tabId === TAB_ADVISOR
|
||||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||||
: tabId === TAB_HOLDINGS_INTEL
|
||||
? <HoldingsIntelTab />
|
||||
: <WatchlistTab wl={wl} />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
@@ -178,6 +186,8 @@ const StockTrade = () => {
|
||||
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
|
||||
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
|
||||
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
|
||||
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
|
||||
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
|
||||
].map(({ id, icon, label, sub, badge, className: cls }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -198,6 +208,8 @@ const StockTrade = () => {
|
||||
)}
|
||||
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
|
||||
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
|
||||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
155
src/pages/stock/components/HoldingsIntelTab.jsx
Normal file
155
src/pages/stock/components/HoldingsIntelTab.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { stockHoldingsIntel } from '../../../api';
|
||||
|
||||
/* ── action config ────────────────────────────────────────────────── */
|
||||
const ACTION_MAP = {
|
||||
add: { label: '추가매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
||||
hold: { label: '보유', color: '#94a3b8', bg: 'rgba(148,163,184,0.10)' },
|
||||
trim: { label: '축소', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
};
|
||||
|
||||
const SEV_COLOR = { high: '#ef4444', med: '#f59e0b', low: '#94a3b8' };
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────── */
|
||||
const fmtRate = (v) => (v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` : '—');
|
||||
const fmtPct = (v) => (v != null ? `${(v * 100).toFixed(0)}%` : '—');
|
||||
|
||||
/* ── sub-components ───────────────────────────────────────────────── */
|
||||
const HealthBar = ({ ph }) => (
|
||||
<div className="hi-health">
|
||||
<span className={`hi-health__pnl ${(ph.total_pnl_rate ?? 0) >= 0 ? 'is-up' : 'is-down'}`}>
|
||||
포트 손익 {fmtRate(ph.total_pnl_rate)}
|
||||
</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>종목 {ph.positions ?? 0}</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>최대비중 {fmtPct(ph.max_weight)}</span>
|
||||
<span className="hi-health__sep">·</span>
|
||||
<span>현금 {fmtPct(ph.cash_ratio)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const HoldingCard = ({ h }) => {
|
||||
const cfg = ACTION_MAP[h.action] ?? { label: h.action, color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' };
|
||||
const issues = (h.issues || []).slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="hi-card">
|
||||
<div className="hi-card__head">
|
||||
<span
|
||||
className="hi-action-badge"
|
||||
style={{ color: cfg.color, background: cfg.bg }}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
<strong className="hi-card__name">{h.name || h.ticker}</strong>
|
||||
<span className="hi-card__ticker">{h.ticker}</span>
|
||||
{h.close != null && (
|
||||
<span className="hi-card__close">{h.close.toLocaleString()}원</span>
|
||||
)}
|
||||
<span className={`hi-card__pnl ${(h.pnl_rate ?? 0) >= 0 ? 'is-up' : 'is-down'}`}>
|
||||
{fmtRate(h.pnl_rate)}
|
||||
</span>
|
||||
</div>
|
||||
{h.reasons && (
|
||||
<div className="hi-card__reasons">{h.reasons}</div>
|
||||
)}
|
||||
{h.tech_score != null && (
|
||||
<div className="hi-card__score">
|
||||
기술강도 <strong>{h.tech_score.toFixed(0)}</strong>
|
||||
<span className="hi-score-bar" style={{ '--score': `${h.tech_score}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="hi-card__issues">
|
||||
{issues.map((iss, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="hi-issue"
|
||||
style={{ color: SEV_COLOR[iss.severity] ?? '#94a3b8' }}
|
||||
>
|
||||
<span className="hi-issue__dot" style={{ background: SEV_COLOR[iss.severity] ?? '#94a3b8' }} />
|
||||
{iss.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── main component ───────────────────────────────────────────────── */
|
||||
const HoldingsIntelTab = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
stockHoldingsIntel()
|
||||
.then(setData)
|
||||
.catch((err) => setError(err?.message ?? String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const ph = data?.portfolio_health ?? {};
|
||||
const holdings = data?.holdings ?? [];
|
||||
|
||||
return (
|
||||
<section className="stock-panel stock-panel--wide hi-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">보유종목 인텔리전스</p>
|
||||
<h3>보유종목 신호 분석</h3>
|
||||
<p className="stock-panel__sub">
|
||||
스크리너 엔진 기반 기술분석·매도룰·이슈를 보유종목에 적용합니다 (어드바이저리).
|
||||
</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">
|
||||
{loading && <Loading type="spinner" message="" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="stock-error">{error}</p>}
|
||||
|
||||
{!loading && !error && !data && (
|
||||
<p className="stock-empty">데이터가 없습니다.</p>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<>
|
||||
{Object.keys(ph).length > 0 && <HealthBar ph={ph} />}
|
||||
|
||||
{data.date && (
|
||||
<p className="hi-date">분석 기준일: {data.date}</p>
|
||||
)}
|
||||
|
||||
{holdings.length === 0 ? (
|
||||
<div className="hi-empty">
|
||||
<span className="hi-empty__icon">📊</span>
|
||||
<p>아직 분석 데이터가 없습니다.</p>
|
||||
<p className="hi-empty__sub">
|
||||
보유종목 등록 후 EOD 계산이 완료되면 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hi-cards">
|
||||
{holdings.map((h) => (
|
||||
<HoldingCard key={h.ticker} h={h} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">
|
||||
※ 투자 판단 보조용 제안입니다. 자동매매가 아니며 최종 결정은 본인 책임입니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoldingsIntelTab;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user