diff --git a/.gitignore b/.gitignore
index febb0ba..d266973 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
node_modules
dist
.claude
+.mcp.json
diff --git a/MODULARIZATION.md b/MODULARIZATION.md
new file mode 100644
index 0000000..f2c8c38
--- /dev/null
+++ b/MODULARIZATION.md
@@ -0,0 +1,133 @@
+# App.js Modularization
+
+## Overview
+
+Original `app.js` (1370 lines) has been split into 7 ES6 modules totaling 1427 lines.
+
+## Module Structure
+
+```
+js/
+├── config.js (136 lines) - Constants and configuration
+├── api.js (56 lines) - API service layer
+├── utils.js (132 lines) - Utility functions
+├── wizard.js (199 lines) - Server recommendation wizard
+├── pricing.js (502 lines) - Pricing table component
+├── dashboard.js (350 lines) - Dashboard and Telegram integration
+└── app.js (52 lines) - Main entry point
+```
+
+## Module Responsibilities
+
+### config.js
+- `TELEGRAM_BOT_URL` - Telegram bot URL constant
+- `PRICING_DATA` - Legacy pricing data (VAT included, monthly basis)
+- `MOCK_SERVERS`, `MOCK_STATS`, `MOCK_NOTIFICATIONS` - Mock data for UI testing
+- `WIZARD_CONFIG` - Server recommendation wizard configuration
+
+### api.js
+- `API_CONFIG` - API configuration (base URL, timeout, etc.)
+- `ApiService` - API request helper with error handling
+
+### utils.js
+- `formatPrice()` - KRW price formatting
+- `switchTab()` - Tab switching (n8n/Terraform)
+- `updatePing()`, `startPingUpdates()`, `stopPingUpdates()` - Ping simulation
+- DOM event listeners for visibility change and keyboard navigation
+
+### wizard.js
+- `calculateRecommendation()` - Rule-based server recommendation logic
+- `createWizardMethods()` - Wizard state and methods for Alpine.js
+
+### pricing.js
+- `pricingTable()` - Pricing table component with API integration
+- Caching, filtering, sorting, and modal interactions
+- Supports Global (Tokyo/Osaka/Singapore) and Seoul regions
+- Subtabs for instance types (Standard/Dedicated/Premium/High Memory)
+
+### dashboard.js
+- `createDashboardMethods()` - Dashboard state and methods
+- Telegram Mini App integration
+- Web login widget support (Telegram Login Widget)
+- Server management (start/stop/delete)
+- Stats and notifications
+
+### app.js
+- Module imports and integration
+- `anvilApp()` - Main Alpine.js data function (merges wizard + dashboard)
+- Global function exposure (`window.anvilApp`, `window.pricingTable`)
+- `window.onTelegramAuth()` - Telegram web login callback
+- `window.AnvilDevTools` - Development tools
+
+## Usage in HTML
+
+```html
+
+
+
+
+
+```
+
+## Alpine.js Integration
+
+All modules expose functions to `window` for Alpine.js `x-data`:
+
+```html
+
+
+
+
+
+
+
+```
+
+## Benefits
+
+1. **Separation of Concerns**: Each module has a single responsibility
+2. **Maintainability**: Easier to find and modify specific functionality
+3. **Testability**: Individual modules can be tested independently
+4. **Code Reuse**: Modules can be imported as needed
+5. **ES6 Features**: Modern JavaScript syntax (import/export)
+6. **Alpine.js Compatible**: All functions exposed to `window` for x-data usage
+
+## Cloudflare Pages Compatibility
+
+ES6 modules work natively in modern browsers. Cloudflare Pages serves them with correct MIME types:
+
+- `.js` files → `application/javascript`
+- No build step required
+- Works with CSP (Content Security Policy)
+
+## Deployment
+
+```bash
+wrangler pages deploy . --project-name anvil-hosting
+```
+
+No build step required - deploy directly.
+
+## Testing
+
+1. Open browser console at https://hosting.anvil.it.com
+2. Check for `[Anvil] Application modules loaded` message
+3. Test `window.AnvilDevTools` object
+4. Verify wizard modal opens correctly
+5. Verify pricing table loads and filters work
+6. Test Telegram Mini App integration (if in Telegram environment)
+
+## Migration Notes
+
+- Original `app.js` backed up as `app.js.backup`
+- All functionality preserved
+- No breaking changes to HTML templates
+- Alpine.js integration unchanged
+
+## Future Improvements
+
+1. Add unit tests for each module
+2. Implement TypeScript for type safety
+3. Consider bundling for production (Vite, Rollup)
+4. Add module documentation (JSDoc)
+5. Implement code splitting for lazy loading
diff --git a/TELEGRAM_INTEGRATION.md b/TELEGRAM_INTEGRATION.md
new file mode 100644
index 0000000..bc39028
--- /dev/null
+++ b/TELEGRAM_INTEGRATION.md
@@ -0,0 +1,131 @@
+# Telegram Mini App Integration - Implementation Summary
+
+## Changes Made
+
+### 1. index.html Modifications
+
+#### Added Telegram Web App SDK (Line 35)
+```html
+
+```
+
+#### Updated Content Security Policy (Line 6)
+- Added `https://telegram.org` to `script-src` directive
+- Allows loading Telegram SDK from official CDN
+
+#### Added User Info Display in Navbar (Lines 86-93)
+Two conditional displays:
+- **Telegram Mode**: Shows user info `👤 @username (ID: 123456)`
+- **Web Browser Mode**: Shows `🌐 웹 브라우저`
+
+### 2. app.js Modifications
+
+#### Added Telegram State (Lines 88-92)
+```javascript
+telegram: {
+ isAvailable: false, // Whether running in Telegram environment
+ user: null, // User information object
+ initData: null // Validation data
+}
+```
+
+#### Added telegram_id to config (Line 100)
+```javascript
+config: {
+ region: null,
+ plan: null,
+ os: null,
+ payment: null,
+ telegram_id: null // NEW: Telegram user ID
+}
+```
+
+#### Added init() Method (Lines 120-140)
+- Detects Telegram Web App environment
+- Initializes Telegram SDK (`tg.ready()`, `tg.expand()`)
+- Stores user information and init data
+- Logs initialization status
+
+#### Updated startLaunch() Method (Lines 227-229)
+- Automatically includes `telegram_id` when creating server
+- Only adds ID if user is authenticated via Telegram
+
+#### Updated resetLauncher() Method (Line 281)
+- Resets `telegram_id` along with other config values
+
+## Features
+
+### Backward Compatibility
+- ✅ Works in regular web browsers without errors
+- ✅ Gracefully degrades when Telegram SDK is unavailable
+- ✅ All existing functionality preserved
+
+### Telegram Integration
+- ✅ Auto-detects Telegram Mini App environment
+- ✅ Displays user information in navbar
+- ✅ Includes telegram_id in server creation payload
+- ✅ Console logging for debugging
+
+### Security
+- ✅ CSP updated to allow Telegram SDK
+- ✅ Optional chaining prevents errors
+- ✅ initData available for backend verification
+
+## Testing Scenarios
+
+### 1. Web Browser Test
+**Expected**:
+- `telegram.isAvailable = false`
+- Shows "🌐 웹 브라우저" badge
+- Server creation works without telegram_id
+- No console errors
+
+### 2. Telegram Mini App Test
+**Expected**:
+- `telegram.isAvailable = true`
+- Shows "👤 @username (ID: 123456)" badge
+- Server creation includes `telegram_id` field
+- Console logs show user info
+
+## Usage
+
+### For End Users
+1. Open in Telegram: Access via bot inline button
+2. Open in Browser: Direct URL access
+
+### For Developers
+```javascript
+// Access Telegram data in console
+console.log(window.Telegram?.WebApp?.initDataUnsafe);
+
+// Check if running in Telegram
+Alpine.store('telegram.isAvailable');
+
+// Get user ID
+Alpine.store('telegram.user.id');
+```
+
+## API Integration
+
+When backend receives server creation request, `config` object will include:
+
+```json
+{
+ "region": "Seoul",
+ "plan": "Pro",
+ "os": "Debian 12",
+ "payment": "yearly",
+ "telegram_id": 123456789 // Only present when launched from Telegram
+}
+```
+
+Backend can use `telegram_id` to:
+- Link server to Telegram user
+- Send notifications via bot
+- Verify user identity using `initData`
+
+## Notes
+
+- Theme color integration is commented out (Line 131)
+- Can be enabled if desired: `document.body.style.backgroundColor = tg.backgroundColor;`
+- initData should be validated on backend for security
diff --git a/app.js b/app.js.backup
similarity index 100%
rename from app.js
rename to app.js.backup
diff --git a/design-system/README.md b/design-system/README.md
new file mode 100644
index 0000000..4810df7
--- /dev/null
+++ b/design-system/README.md
@@ -0,0 +1,230 @@
+# Anvil Hosting Design System
+
+Figma 라이브러리 구축을 위한 디자인 토큰 및 가이드입니다.
+
+## 📦 파일 구조
+
+```
+design-system/
+├── tokens.json # 전체 디자인 토큰 (개발용)
+├── figma-tokens.json # Figma Tokens 플러그인 호환 형식
+└── README.md # 이 파일
+```
+
+## 🎨 색상 시스템
+
+### Brand Colors (Sky Blue)
+| Token | HEX | 용도 |
+|-------|-----|------|
+| `brand-400` | `#38bdf8` | Hover, 강조, 아이콘 |
+| `brand-500` | `#0ea5e9` | **Primary** - 버튼, 링크 |
+| `brand-600` | `#0284c7` | Pressed 상태 |
+
+### Purple (Secondary)
+| Token | HEX | 용도 |
+|-------|-----|------|
+| `purple-400` | `#c084fc` | 밝은 악센트 |
+| `purple-500` | `#a855f7` | 그라디언트 종점 |
+| `purple-600` | `#9333ea` | 어두운 악센트 |
+
+### Dark (Backgrounds)
+| Token | HEX | 용도 |
+|-------|-----|------|
+| `dark-900` | `#0b1120` | **페이지 배경** |
+| `dark-800` | `#1e293b` | 카드/패널 배경 |
+| `dark-700` | `#334155` | 테두리, 구분선 |
+
+### Semantic Colors
+| Token | HEX | 용도 |
+|-------|-----|------|
+| `success` | `#22c55e` | 성공, 활성 상태 |
+| `warning` | `#eab308` | 경고 |
+| `error` | `#ef4444` | 에러, 삭제 |
+| `info` | `#3b82f6` | 정보 |
+
+## 📝 타이포그래피
+
+### 폰트 패밀리
+```css
+/* 본문 (한글 최적화) */
+font-family: -apple-system, BlinkMacSystemFont,
+ 'Apple SD Gothic Neo', 'Malgun Gothic',
+ 'Noto Sans KR', system-ui, sans-serif;
+
+/* 코드 */
+font-family: 'JetBrains Mono', monospace;
+```
+
+### 폰트 크기 스케일
+| Token | Size | Line Height | 용도 |
+|-------|------|-------------|------|
+| `xs` | 12px | 16px | 라벨, 캡션 |
+| `sm` | 14px | 20px | 보조 텍스트 |
+| `base` | 16px | 24px | **본문** |
+| `lg` | 18px | 28px | 강조 본문 |
+| `xl` | 20px | 28px | 부제목 |
+| `2xl` | 24px | 32px | 카드 제목 |
+| `3xl` | 30px | 36px | 섹션 제목 |
+| `4xl` | 36px | 40px | 페이지 제목 |
+| `5xl` | 48px | 48px | Hero 제목 |
+
+## ✨ 글래스모피즘 효과
+
+### Glass Panel (네비게이션, 모달)
+```css
+background: rgba(30, 41, 59, 0.4);
+backdrop-filter: blur(12px);
+border: 1px solid rgba(255, 255, 255, 0.05);
+```
+
+### Glass Card (피처 카드, 가격표)
+```css
+/* 기본 상태 */
+background: linear-gradient(135deg,
+ rgba(30, 41, 59, 0.6) 0%,
+ rgba(30, 41, 59, 0.3) 100%);
+backdrop-filter: blur(20px);
+border: 1px solid rgba(255, 255, 255, 0.08);
+
+/* Hover 상태 */
+background: linear-gradient(135deg,
+ rgba(30, 41, 59, 0.8) 0%,
+ rgba(30, 41, 59, 0.5) 100%);
+border-color: rgba(255, 255, 255, 0.15);
+box-shadow:
+ 0 20px 40px rgba(0, 0, 0, 0.3),
+ 0 0 40px rgba(56, 189, 248, 0.1);
+transform: translateY(-4px);
+```
+
+## 🌈 그라디언트
+
+### Brand Gradient (CTA, Hero)
+```css
+background: linear-gradient(135deg, #38bdf8 0%, #a855f7 100%);
+```
+
+### Text Gradient
+```css
+background: linear-gradient(135deg, #38bdf8 0%, #a855f7 50%, #38bdf8 100%);
+background-size: 200% 100%;
+-webkit-background-clip: text;
+color: transparent;
+```
+
+### Soft Gradient (배경)
+```css
+background: linear-gradient(135deg,
+ rgba(56, 189, 248, 0.15) 0%,
+ rgba(168, 85, 247, 0.1) 100%);
+```
+
+## 💫 Glow 효과
+
+### Brand Glow
+```css
+box-shadow: 0 0 40px rgba(56, 189, 248, 0.3);
+```
+
+### Purple Glow
+```css
+box-shadow: 0 0 40px rgba(168, 85, 247, 0.3);
+```
+
+## 📐 간격 시스템
+
+| Token | Value | 용도 |
+|-------|-------|------|
+| `1` | 4px | 아이콘 갭 |
+| `2` | 8px | 인라인 요소 |
+| `3` | 12px | 밀집 UI |
+| `4` | 16px | **기본 갭** |
+| `6` | 24px | 카드 패딩 |
+| `8` | 32px | 섹션 내부 |
+| `16` | 64px | 섹션 간 |
+| `24` | 96px | 대형 섹션 |
+
+## 🔘 Border Radius
+
+| Token | Value | 용도 |
+|-------|-------|------|
+| `sm` | 4px | 작은 버튼, 태그 |
+| `lg` | 8px | 입력 필드 |
+| `xl` | 12px | 카드 |
+| `2xl` | 16px | 대형 카드 |
+| `3xl` | 24px | 모달, 패널 |
+| `full` | 9999px | 원형 버튼, 아바타 |
+
+---
+
+## 🛠 Figma 설정 가이드
+
+### 1. Figma Tokens 플러그인 사용 (권장)
+
+1. Figma에서 **Figma Tokens** 플러그인 설치
+2. `figma-tokens.json` 파일 내용을 플러그인에 Import
+3. 토큰이 자동으로 스타일 생성
+
+### 2. 수동 설정
+
+**Color Styles 생성:**
+1. Figma → Local Styles → + 버튼
+2. 위 색상표 참고하여 스타일 생성
+3. 명명 규칙: `brand/500`, `dark/900`, `semantic/success`
+
+**Text Styles 생성:**
+1. 텍스트 작성 → 스타일 패널 → +
+2. 명명 규칙: `heading/5xl`, `body/base`, `code/sm`
+
+**Effect Styles 생성:**
+1. 요소에 효과 적용 → 스타일 패널 → +
+2. 명명 규칙: `glass-panel`, `glass-card`, `glow-brand`
+
+---
+
+## 📱 컴포넌트 목록
+
+WireFramer 라이브러리와 연계하여 구축할 컴포넌트:
+
+### Navigation
+- [ ] Header
+- [ ] Footer
+- [ ] Tab Bar (가격표용)
+- [ ] Mobile Menu
+
+### Content
+- [ ] Hero Section
+- [ ] Feature Card
+- [ ] Pricing Card
+- [ ] Info Card
+
+### Inputs
+- [ ] Text Input
+- [ ] Select/Dropdown
+- [ ] Slider (리소스 선택)
+- [ ] Toggle
+- [ ] Radio Group (OS/리전 선택)
+
+### Buttons
+- [ ] Primary Button (Gradient)
+- [ ] Secondary Button (Glass)
+- [ ] Icon Button
+- [ ] Link Button
+
+### Feedback
+- [ ] Badge/Tag
+- [ ] Toast/Alert
+- [ ] Loading Spinner
+- [ ] Skeleton
+
+---
+
+## 🔗 관련 파일
+
+- `/src/input.css` - Tailwind CSS 소스
+- `/style.css` - 빌드된 CSS
+- `/index.html` - 메인 페이지
+
+## 📅 버전
+
+- **v1.0** (2026-01-23): 초기 디자인 토큰 추출
diff --git a/design-system/figma-tokens.json b/design-system/figma-tokens.json
new file mode 100644
index 0000000..2354388
--- /dev/null
+++ b/design-system/figma-tokens.json
@@ -0,0 +1,112 @@
+{
+ "global": {
+ "colors": {
+ "brand": {
+ "400": { "value": "#38bdf8" },
+ "500": { "value": "#0ea5e9" },
+ "600": { "value": "#0284c7" }
+ },
+ "purple": {
+ "400": { "value": "#c084fc" },
+ "500": { "value": "#a855f7" },
+ "600": { "value": "#9333ea" }
+ },
+ "dark": {
+ "700": { "value": "#334155" },
+ "800": { "value": "#1e293b" },
+ "900": { "value": "#0b1120" }
+ },
+ "slate": {
+ "200": { "value": "#e2e8f0" },
+ "300": { "value": "#cbd5e1" },
+ "400": { "value": "#94a3b8" },
+ "500": { "value": "#64748b" },
+ "700": { "value": "#334155" }
+ },
+ "success": { "value": "#22c55e" },
+ "warning": { "value": "#eab308" },
+ "error": { "value": "#ef4444" },
+ "info": { "value": "#3b82f6" }
+ },
+ "fontFamilies": {
+ "sans": { "value": "Apple SD Gothic Neo, Malgun Gothic, Noto Sans KR" },
+ "mono": { "value": "JetBrains Mono" }
+ },
+ "fontSizes": {
+ "xs": { "value": "12" },
+ "sm": { "value": "14" },
+ "base": { "value": "16" },
+ "lg": { "value": "18" },
+ "xl": { "value": "20" },
+ "2xl": { "value": "24" },
+ "3xl": { "value": "30" },
+ "4xl": { "value": "36" },
+ "5xl": { "value": "48" }
+ },
+ "fontWeights": {
+ "normal": { "value": "400" },
+ "medium": { "value": "500" },
+ "semibold": { "value": "600" },
+ "bold": { "value": "700" }
+ },
+ "lineHeights": {
+ "tight": { "value": "1.25" },
+ "normal": { "value": "1.5" },
+ "relaxed": { "value": "1.625" }
+ },
+ "spacing": {
+ "1": { "value": "4" },
+ "2": { "value": "8" },
+ "3": { "value": "12" },
+ "4": { "value": "16" },
+ "6": { "value": "24" },
+ "8": { "value": "32" },
+ "12": { "value": "48" },
+ "16": { "value": "64" },
+ "24": { "value": "96" }
+ },
+ "borderRadius": {
+ "sm": { "value": "4" },
+ "md": { "value": "6" },
+ "lg": { "value": "8" },
+ "xl": { "value": "12" },
+ "2xl": { "value": "16" },
+ "3xl": { "value": "24" }
+ },
+ "opacity": {
+ "5": { "value": "5%" },
+ "10": { "value": "10%" },
+ "20": { "value": "20%" },
+ "30": { "value": "30%" },
+ "50": { "value": "50%" },
+ "80": { "value": "80%" }
+ }
+ },
+ "semantic": {
+ "bg": {
+ "primary": { "value": "{colors.dark.900}" },
+ "secondary": { "value": "{colors.dark.800}" },
+ "card": { "value": "{colors.dark.800}" }
+ },
+ "text": {
+ "primary": { "value": "{colors.slate.200}" },
+ "secondary": { "value": "{colors.slate.300}" },
+ "muted": { "value": "{colors.slate.400}" },
+ "brand": { "value": "{colors.brand.400}" }
+ },
+ "border": {
+ "default": { "value": "{colors.slate.700}" },
+ "subtle": { "value": "rgba(255,255,255,0.05)" },
+ "brand": { "value": "{colors.brand.500}" }
+ },
+ "interactive": {
+ "primary": { "value": "{colors.brand.500}" },
+ "primaryHover": { "value": "{colors.brand.400}" },
+ "secondary": { "value": "{colors.purple.500}" }
+ }
+ },
+ "$themes": [],
+ "$metadata": {
+ "tokenSetOrder": ["global", "semantic"]
+ }
+}
diff --git a/design-system/tokens.json b/design-system/tokens.json
new file mode 100644
index 0000000..b0c6d03
--- /dev/null
+++ b/design-system/tokens.json
@@ -0,0 +1,149 @@
+{
+ "colors": {
+ "brand": {
+ "400": { "value": "#38bdf8", "type": "color", "description": "Sky blue light - hover states, accents" },
+ "500": { "value": "#0ea5e9", "type": "color", "description": "Primary brand color - buttons, links" },
+ "600": { "value": "#0284c7", "type": "color", "description": "Brand dark - pressed states" },
+ "900": { "value": "#0c4a6e", "type": "color", "description": "Brand darkest - subtle backgrounds" }
+ },
+ "purple": {
+ "400": { "value": "#c084fc", "type": "color", "description": "Purple light - accents" },
+ "500": { "value": "#a855f7", "type": "color", "description": "Purple primary - secondary accent" },
+ "600": { "value": "#9333ea", "type": "color", "description": "Purple dark" }
+ },
+ "dark": {
+ "700": { "value": "#334155", "type": "color", "description": "Dark light - borders, dividers" },
+ "800": { "value": "#1e293b", "type": "color", "description": "Dark medium - cards, panels" },
+ "900": { "value": "#0b1120", "type": "color", "description": "Dark deepest - page background" }
+ },
+ "slate": {
+ "100": { "value": "#f1f5f9", "type": "color" },
+ "200": { "value": "#e2e8f0", "type": "color", "description": "Primary text color" },
+ "300": { "value": "#cbd5e1", "type": "color", "description": "Secondary text" },
+ "400": { "value": "#94a3b8", "type": "color", "description": "Muted text" },
+ "500": { "value": "#64748b", "type": "color", "description": "Disabled text" },
+ "700": { "value": "#334155", "type": "color", "description": "Borders" }
+ },
+ "semantic": {
+ "success": { "value": "#22c55e", "type": "color", "description": "Green - success states" },
+ "warning": { "value": "#eab308", "type": "color", "description": "Yellow - warning states" },
+ "error": { "value": "#ef4444", "type": "color", "description": "Red - error states" },
+ "info": { "value": "#3b82f6", "type": "color", "description": "Blue - info states" }
+ }
+ },
+ "typography": {
+ "fontFamily": {
+ "sans": { "value": "-apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', system-ui, sans-serif", "type": "fontFamily" },
+ "mono": { "value": "'JetBrains Mono', monospace", "type": "fontFamily" }
+ },
+ "fontSize": {
+ "xs": { "value": "12px", "lineHeight": "16px" },
+ "sm": { "value": "14px", "lineHeight": "20px" },
+ "base": { "value": "16px", "lineHeight": "24px" },
+ "lg": { "value": "18px", "lineHeight": "28px" },
+ "xl": { "value": "20px", "lineHeight": "28px" },
+ "2xl": { "value": "24px", "lineHeight": "32px" },
+ "3xl": { "value": "30px", "lineHeight": "36px" },
+ "4xl": { "value": "36px", "lineHeight": "40px" },
+ "5xl": { "value": "48px", "lineHeight": "48px" },
+ "6xl": { "value": "60px", "lineHeight": "60px" }
+ },
+ "fontWeight": {
+ "normal": { "value": "400" },
+ "medium": { "value": "500" },
+ "semibold": { "value": "600" },
+ "bold": { "value": "700" }
+ },
+ "letterSpacing": {
+ "tight": { "value": "-0.025em" },
+ "normal": { "value": "0" },
+ "wide": { "value": "0.025em" }
+ }
+ },
+ "spacing": {
+ "0": { "value": "0px" },
+ "1": { "value": "4px" },
+ "2": { "value": "8px" },
+ "3": { "value": "12px" },
+ "4": { "value": "16px" },
+ "5": { "value": "20px" },
+ "6": { "value": "24px" },
+ "8": { "value": "32px" },
+ "10": { "value": "40px" },
+ "12": { "value": "48px" },
+ "16": { "value": "64px" },
+ "20": { "value": "80px" },
+ "24": { "value": "96px" }
+ },
+ "borderRadius": {
+ "sm": { "value": "4px" },
+ "md": { "value": "6px" },
+ "lg": { "value": "8px" },
+ "xl": { "value": "12px" },
+ "2xl": { "value": "16px" },
+ "3xl": { "value": "24px" },
+ "full": { "value": "9999px" }
+ },
+ "effects": {
+ "blur": {
+ "sm": { "value": "8px" },
+ "md": { "value": "12px" },
+ "lg": { "value": "16px" },
+ "xl": { "value": "24px" },
+ "3xl": { "value": "64px" }
+ },
+ "shadow": {
+ "sm": { "value": "0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1)" },
+ "md": { "value": "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)" },
+ "lg": { "value": "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)" },
+ "xl": { "value": "0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1)" },
+ "2xl": { "value": "0 25px 50px -12px rgba(0,0,0,0.25)" },
+ "glow-brand": { "value": "0 0 40px rgba(56, 189, 248, 0.3)" },
+ "glow-purple": { "value": "0 0 40px rgba(168, 85, 247, 0.3)" }
+ }
+ },
+ "components": {
+ "glassPanel": {
+ "background": "rgba(30, 41, 59, 0.4)",
+ "backdropBlur": "12px",
+ "border": "1px solid rgba(255, 255, 255, 0.05)"
+ },
+ "glassCard": {
+ "background": "linear-gradient(135deg, rgba(30, 41, 59, 0.6) 0%, rgba(30, 41, 59, 0.3) 100%)",
+ "backdropBlur": "20px",
+ "border": "1px solid rgba(255, 255, 255, 0.08)",
+ "hoverBackground": "linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(30, 41, 59, 0.5) 100%)",
+ "hoverBorder": "1px solid rgba(255, 255, 255, 0.15)",
+ "hoverShadow": "0 20px 40px rgba(0, 0, 0, 0.3), 0 0 40px rgba(56, 189, 248, 0.1)"
+ },
+ "gradientText": {
+ "background": "linear-gradient(135deg, #38bdf8 0%, #a855f7 50%, #38bdf8 100%)",
+ "backgroundSize": "200% 100%"
+ },
+ "button": {
+ "primary": {
+ "background": "linear-gradient(135deg, #0ea5e9, #38bdf8)",
+ "hoverBackground": "linear-gradient(135deg, #38bdf8, #0ea5e9)",
+ "shadow": "0 0 30px rgba(56, 189, 248, 0.5)"
+ },
+ "secondary": {
+ "background": "rgba(30, 41, 59, 0.6)",
+ "border": "1px solid rgba(255, 255, 255, 0.1)"
+ }
+ }
+ },
+ "gradients": {
+ "brandPurple": {
+ "value": "linear-gradient(135deg, #38bdf8 0%, #a855f7 100%)",
+ "description": "Primary gradient - hero, CTAs"
+ },
+ "brandPurpleSoft": {
+ "value": "linear-gradient(135deg, rgba(56, 189, 248, 0.15) 0%, rgba(168, 85, 247, 0.1) 100%)",
+ "description": "Soft gradient - backgrounds"
+ },
+ "darkGlass": {
+ "value": "linear-gradient(135deg, rgba(30, 41, 59, 0.6) 0%, rgba(30, 41, 59, 0.3) 100%)",
+ "description": "Glass card background"
+ }
+ }
+}
diff --git a/favicon.svg b/favicon.svg
new file mode 100644
index 0000000..8214789
--- /dev/null
+++ b/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/functions/_shared/proxy.ts b/functions/_shared/proxy.ts
deleted file mode 100644
index 840920d..0000000
--- a/functions/_shared/proxy.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * Shared proxy utilities for Cloudflare Pages Functions
- * Handles CORS, error responses, and Worker API forwarding
- */
-
-export interface Env {
- WORKER_API_KEY: string;
- WORKER_API_URL: string;
- DB: D1Database;
-}
-
-export interface ErrorResponse {
- success: false;
- error: string;
- details?: any;
-}
-
-const CORS_HEADERS = {
- 'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
-} as const;
-
-/**
- * Create CORS preflight response
- */
-export function createCorsPreflightResponse(): Response {
- return new Response(null, {
- status: 204,
- headers: CORS_HEADERS,
- });
-}
-
-/**
- * Create error response with CORS headers
- */
-export function createErrorResponse(
- error: string,
- status: number = 500,
- details?: any
-): Response {
- const body: ErrorResponse = {
- success: false,
- error,
- ...(details && { details }),
- };
-
- return new Response(JSON.stringify(body), {
- status,
- headers: {
- ...CORS_HEADERS,
- 'Content-Type': 'application/json',
- },
- });
-}
-
-/**
- * Proxy request to Worker API with authentication
- */
-export async function proxyToWorker(
- env: Env,
- path: string,
- options: RequestInit = {}
-): Promise {
- const workerUrl = `${env.WORKER_API_URL}${path}`;
-
- try {
- const response = await fetch(workerUrl, {
- ...options,
- headers: {
- ...options.headers,
- 'X-API-Key': env.WORKER_API_KEY,
- },
- });
-
- // Clone response to add CORS headers
- const body = await response.text();
- return new Response(body, {
- status: response.status,
- statusText: response.statusText,
- headers: {
- ...CORS_HEADERS,
- 'Content-Type': response.headers.get('Content-Type') || 'application/json',
- },
- });
- } catch (error) {
- console.error(`[Proxy] Failed to fetch ${workerUrl}:`, error);
- return createErrorResponse(
- 'Failed to connect to API server',
- 503,
- error instanceof Error ? error.message : String(error)
- );
- }
-}
-
-/**
- * Build query string from URL search params
- */
-export function buildQueryString(searchParams: URLSearchParams): string {
- const params = searchParams.toString();
- return params ? `?${params}` : '';
-}
diff --git a/functions/api/health.ts b/functions/api/health.ts
deleted file mode 100644
index d16d8fb..0000000
--- a/functions/api/health.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Health check endpoint
- * GET /api/health → Worker GET /health
- */
-
-import { type PagesFunction } from '@cloudflare/workers-types';
-import { Env, createCorsPreflightResponse, proxyToWorker } from '../_shared/proxy';
-
-export const onRequestGet: PagesFunction = async ({ env }) => {
- return proxyToWorker(env, '/health', {
- method: 'GET',
- });
-};
-
-export const onRequestOptions: PagesFunction = async () => {
- return createCorsPreflightResponse();
-};
diff --git a/functions/api/instances.ts b/functions/api/instances.ts
deleted file mode 100644
index b11b411..0000000
--- a/functions/api/instances.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Instances query endpoint
- * GET /api/instances → Worker GET /instances
- */
-
-import { type PagesFunction } from '@cloudflare/workers-types';
-import {
- Env,
- createCorsPreflightResponse,
- proxyToWorker,
- buildQueryString,
-} from '../_shared/proxy';
-
-export const onRequestGet: PagesFunction = async ({ request, env }) => {
- const url = new URL(request.url);
- const queryString = buildQueryString(url.searchParams);
-
- return proxyToWorker(env, `/instances${queryString}`, {
- method: 'GET',
- });
-};
-
-export const onRequestOptions: PagesFunction = async () => {
- return createCorsPreflightResponse();
-};
diff --git a/functions/api/pricing.ts b/functions/api/pricing.ts
index 5ae0ea5..b5a7f01 100644
--- a/functions/api/pricing.ts
+++ b/functions/api/pricing.ts
@@ -8,7 +8,10 @@
*/
import { type PagesFunction } from '@cloudflare/workers-types';
-import { Env, createCorsPreflightResponse } from '../_shared/proxy';
+
+interface Env {
+ DB: D1Database;
+}
interface InstanceRow {
instance_id: string;
@@ -201,5 +204,5 @@ export const onRequestGet: PagesFunction = async ({ env }) => {
};
export const onRequestOptions: PagesFunction = async () => {
- return createCorsPreflightResponse();
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
};
diff --git a/functions/api/recommend.ts b/functions/api/recommend.ts
deleted file mode 100644
index 769d62d..0000000
--- a/functions/api/recommend.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Recommendation endpoint
- * POST /api/recommend → Worker POST /recommend
- */
-
-import { type PagesFunction } from '@cloudflare/workers-types';
-import {
- Env,
- createCorsPreflightResponse,
- createErrorResponse,
- proxyToWorker,
-} from '../_shared/proxy';
-
-export const onRequestPost: PagesFunction = async ({ request, env }) => {
- try {
- // Read request body
- const body = await request.text();
-
- // Validate JSON
- if (body) {
- try {
- JSON.parse(body);
- } catch {
- return createErrorResponse('Invalid JSON in request body', 400);
- }
- }
-
- return proxyToWorker(env, '/recommend', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body,
- });
- } catch (error) {
- console.error('[Recommend] Failed to process request:', error);
- return createErrorResponse(
- 'Failed to process recommendation request',
- 500,
- error instanceof Error ? error.message : String(error)
- );
- }
-};
-
-export const onRequestOptions: PagesFunction = async () => {
- return createCorsPreflightResponse();
-};
diff --git a/index.html b/index.html
index 30547d0..c70ecdf 100644
--- a/index.html
+++ b/index.html
@@ -119,7 +119,7 @@
-
+
diff --git a/js/api.js b/js/api.js
new file mode 100644
index 0000000..ee874a1
--- /dev/null
+++ b/js/api.js
@@ -0,0 +1,56 @@
+/**
+ * API Service
+ * cloud-instances-api 워커 호출
+ */
+
+// API 설정 (프록시 사용 - /api/* 경로)
+export const API_CONFIG = {
+ baseUrl: '/api', // Cloudflare Pages Functions 프록시 사용
+ apiKey: null, // 프록시에서 처리
+ timeout: 10000
+};
+
+/**
+ * API 서비스 객체
+ */
+export const ApiService = {
+ /**
+ * API 요청 헬퍼
+ */
+ async request(endpoint, options = {}) {
+ const url = `${API_CONFIG.baseUrl}${endpoint}`;
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...(API_CONFIG.apiKey && { 'X-API-Key': API_CONFIG.apiKey }),
+ ...options.headers
+ };
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
+
+ const response = await fetch(url, {
+ ...options,
+ headers,
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ message: response.statusText }));
+ throw new Error(error.message || `HTTP ${response.status}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ throw new Error('요청 시간이 초과되었습니다.');
+ }
+ throw error;
+ }
+ }
+
+ // Removed: health(), getInstances(), recommend(), sync()
+ // These methods referenced deleted API functions (health.ts, instances.ts, recommend.ts)
+};
diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..bb619c5
--- /dev/null
+++ b/js/app.js
@@ -0,0 +1,52 @@
+/**
+ * Anvil Hosting - Main Application JavaScript
+ * 메인 통합 모듈 (ES6 모듈 방식)
+ */
+
+// 모듈 임포트
+import { TELEGRAM_BOT_URL, PRICING_DATA, MOCK_SERVERS, MOCK_STATS, MOCK_NOTIFICATIONS, WIZARD_CONFIG } from './config.js';
+import { API_CONFIG, ApiService } from './api.js';
+import { formatPrice, switchTab, updatePing, startPingUpdates, stopPingUpdates } from './utils.js';
+import { calculateRecommendation, createWizardMethods } from './wizard.js';
+import { createDashboardMethods } from './dashboard.js';
+import { pricingTable } from './pricing.js';
+
+/**
+ * Alpine.js 메인 앱 데이터 - 대화형 위자드 + 대시보드
+ */
+function anvilApp() {
+ // 마법사 메서드와 대시보드 메서드 병합
+ const wizardMethods = createWizardMethods();
+ const dashboardMethods = createDashboardMethods();
+
+ return {
+ ...wizardMethods,
+ ...dashboardMethods
+ };
+}
+
+// 전역 함수로 노출 (Alpine.js x-data에서 사용)
+window.anvilApp = anvilApp;
+window.pricingTable = pricingTable;
+
+// 전역 텔레그램 로그인 콜백 (웹 로그인 위젯용)
+window.onTelegramAuth = function(user) {
+ // Alpine 인스턴스 찾기
+ const appElement = document.querySelector('[x-data="anvilApp()"]');
+ if (appElement && appElement._x_dataStack) {
+ const appData = appElement._x_dataStack[0];
+ if (appData.handleWebLogin) {
+ appData.handleWebLogin(user);
+ }
+ }
+};
+
+// 개발 도구 (콘솔에서 사용 가능)
+window.AnvilDevTools = {
+ config: { TELEGRAM_BOT_URL, PRICING_DATA, WIZARD_CONFIG },
+ api: ApiService,
+ utils: { formatPrice, switchTab, updatePing }
+};
+
+console.log('[Anvil] Application modules loaded');
+console.log('[Anvil] DevTools available at window.AnvilDevTools');
diff --git a/js/config.js b/js/config.js
new file mode 100644
index 0000000..f266f5f
--- /dev/null
+++ b/js/config.js
@@ -0,0 +1,136 @@
+/**
+ * Configuration and Constants
+ * 상수, 설정, 목 데이터 정의
+ */
+
+// 텔레그램 봇 URL
+export const TELEGRAM_BOT_URL = 'https://t.me/AnvilForgeBot';
+
+// 단일 가격 데이터 소스 (VAT 포함, 월간 기준)
+export const PRICING_DATA = {
+ global: [
+ { plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '25 GB', transfer: '1 TB', price: 8500 },
+ { plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '50 GB', transfer: '2 TB', price: 20400 },
+ { plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 40700, featured: true },
+ { plan: 'Business', vcpu: '4 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
+ ],
+ seoul: [
+ { plan: 'Nano', vcpu: '1 Core', ram: '512 MB', ssd: '20 GB', transfer: '1 TB', price: 6000 },
+ { plan: 'Micro', vcpu: '1 Core', ram: '1 GB', ssd: '40 GB', transfer: '2 TB', price: 8500 },
+ { plan: 'Starter', vcpu: '1 Core', ram: '2 GB', ssd: '60 GB', transfer: '3 TB', price: 17000 },
+ { plan: 'Pro', vcpu: '2 Cores', ram: '4 GB', ssd: '80 GB', transfer: '4 TB', price: 33900, featured: true },
+ { plan: 'Business', vcpu: '2 Cores', ram: '8 GB', ssd: '160 GB', transfer: '5 TB', price: 67800 }
+ ]
+};
+
+// Mock 데이터 (API 없이 UI 테스트용)
+export const MOCK_SERVERS = [
+ {
+ id: 'srv-001',
+ name: 'production-web',
+ region: '🇯🇵 Tokyo',
+ ip: '45.12.89.101',
+ os: 'Debian 12',
+ plan: 'Pro',
+ status: 'running',
+ created_at: '2026-01-15',
+ vcpu: '2 Cores',
+ ram: '4 GB',
+ cost: 40700
+ },
+ {
+ id: 'srv-002',
+ name: 'dev-api',
+ region: '🇰🇷 Seoul',
+ ip: '45.12.89.102',
+ os: 'Ubuntu 24.04',
+ plan: 'Starter',
+ status: 'stopped',
+ created_at: '2026-01-20',
+ vcpu: '1 Core',
+ ram: '2 GB',
+ cost: 17000
+ }
+];
+
+export const MOCK_STATS = {
+ totalCost: 57700,
+ totalServers: 2,
+ runningServers: 1,
+ costBreakdown: [
+ { plan: 'Pro', count: 1, cost: 40700 },
+ { plan: 'Starter', count: 1, cost: 17000 }
+ ]
+};
+
+export const MOCK_NOTIFICATIONS = [
+ { id: 'n-001', type: 'info', title: '서버 생성 완료', message: 'production-web 서버가 Tokyo 리전에 생성되었습니다.', time: '10분 전', read: false },
+ { id: 'n-002', type: 'warning', title: '결제 예정', message: '이번 달 결제가 3일 후 예정되어 있습니다. (₩57,700)', time: '1시간 전', read: false },
+ { id: 'n-003', type: 'success', title: '시스템 업데이트 완료', message: 'dev-api 서버의 Ubuntu 패키지가 업데이트되었습니다.', time: '2일 전', read: true }
+];
+
+// 서버 추천 마법사 설정
+export const WIZARD_CONFIG = {
+ // 용도 목록 (객체 형태)
+ purposes: {
+ web: { icon: '🌐', name: '웹 서비스', desc: '웹사이트, API, SaaS' },
+ game: { icon: '🎮', name: '게임 서버', desc: '마인크래프트, 발하임 등' },
+ ai: { icon: '🤖', name: 'AI / ML', desc: '머신러닝, LLM, 데이터 분석' },
+ dev: { icon: '💻', name: '개발 환경', desc: 'CI/CD, 테스트, 스테이징' },
+ db: { icon: '🗄️', name: '데이터베이스', desc: 'MySQL, PostgreSQL, Redis' },
+ other: { icon: '📦', name: '기타', desc: 'VPN, 프록시, 기타 용도' }
+ },
+
+ // 기술 스택 (용도별 필터링, category 추가)
+ stacks: {
+ web: [
+ { id: 'wordpress', icon: '📝', name: 'WordPress', ram: 1024, cpu: 1, category: 'CMS' },
+ { id: 'nginx', icon: '🟢', name: 'Nginx / Static', ram: 512, cpu: 1, category: 'Web Server' },
+ { id: 'nodejs', icon: '🟩', name: 'Node.js', ram: 1024, cpu: 1, category: 'Runtime' },
+ { id: 'python', icon: '🐍', name: 'Python / Django', ram: 1024, cpu: 1, category: 'Framework' },
+ { id: 'php', icon: '🐘', name: 'PHP / Laravel', ram: 1024, cpu: 1, category: 'Framework' },
+ { id: 'nextjs', icon: '▲', name: 'Next.js / React', ram: 2048, cpu: 2, category: 'Framework' },
+ { id: 'docker', icon: '🐳', name: 'Docker 컨테이너', ram: 2048, cpu: 2, category: 'Container' }
+ ],
+ game: [
+ { id: 'minecraft', icon: '⛏️', name: 'Minecraft', ram: 4096, cpu: 2, category: 'Sandbox' },
+ { id: 'valheim', icon: '⚔️', name: 'Valheim', ram: 4096, cpu: 2, category: 'Survival' },
+ { id: 'ark', icon: '🦖', name: 'ARK', ram: 8192, cpu: 4, category: 'Survival' },
+ { id: 'palworld', icon: '🐾', name: 'Palworld', ram: 16384, cpu: 4, category: 'Survival' },
+ { id: 'rust', icon: '🔫', name: 'Rust', ram: 8192, cpu: 4, category: 'Survival' },
+ { id: 'terraria', icon: '🌲', name: 'Terraria', ram: 1024, cpu: 1, category: 'Sandbox' }
+ ],
+ ai: [
+ { id: 'jupyter', icon: '📓', name: 'Jupyter Notebook', ram: 4096, cpu: 2, category: 'IDE' },
+ { id: 'ollama', icon: '🦙', name: 'Ollama / LLM', ram: 16384, cpu: 4, category: 'LLM' },
+ { id: 'stable', icon: '🎨', name: 'Stable Diffusion', ram: 16384, cpu: 4, gpu: true, category: 'Image AI' },
+ { id: 'pytorch', icon: '🔥', name: 'PyTorch / TensorFlow', ram: 8192, cpu: 4, category: 'ML Framework' }
+ ],
+ dev: [
+ { id: 'gitlab', icon: '🦊', name: 'GitLab', ram: 4096, cpu: 2, category: 'DevOps' },
+ { id: 'jenkins', icon: '🔧', name: 'Jenkins', ram: 2048, cpu: 2, category: 'CI/CD' },
+ { id: 'n8n', icon: '🔀', name: 'n8n 자동화', ram: 1024, cpu: 1, category: 'Automation' },
+ { id: 'vscode', icon: '💠', name: 'VS Code Server', ram: 2048, cpu: 2, category: 'IDE' }
+ ],
+ db: [
+ { id: 'mysql', icon: '🐬', name: 'MySQL / MariaDB', ram: 2048, cpu: 2, category: 'RDBMS' },
+ { id: 'postgresql', icon: '🐘', name: 'PostgreSQL', ram: 2048, cpu: 2, category: 'RDBMS' },
+ { id: 'mongodb', icon: '🍃', name: 'MongoDB', ram: 2048, cpu: 2, category: 'NoSQL' },
+ { id: 'redis', icon: '🔴', name: 'Redis', ram: 1024, cpu: 1, category: 'Cache' }
+ ],
+ other: [
+ { id: 'vpn', icon: '🔒', name: 'VPN / WireGuard', ram: 512, cpu: 1, category: 'Network' },
+ { id: 'proxy', icon: '🌐', name: '프록시 서버', ram: 512, cpu: 1, category: 'Network' },
+ { id: 'storage', icon: '💾', name: '파일 스토리지', ram: 1024, cpu: 1, category: 'Storage' },
+ { id: 'custom', icon: '⚙️', name: '커스텀 설정', ram: 1024, cpu: 1, category: 'Custom' }
+ ]
+ },
+
+ // 규모 선택 (객체 형태)
+ scales: {
+ personal: { name: '개인', desc: '1-10명', users: '1-10명', multiplier: 1, icon: '👤' },
+ small: { name: '소규모', desc: '10-100명', users: '10-100명', multiplier: 1.5, icon: '👥' },
+ medium: { name: '중규모', desc: '100-1000명', users: '100-1,000명', multiplier: 2.5, icon: '🏢' },
+ large: { name: '대규모', desc: '1000명+', users: '1,000명+', multiplier: 4, icon: '🏙️' }
+ }
+};
diff --git a/js/dashboard.js b/js/dashboard.js
new file mode 100644
index 0000000..2925ad2
--- /dev/null
+++ b/js/dashboard.js
@@ -0,0 +1,350 @@
+/**
+ * Dashboard Component
+ * 텔레그램 대시보드 관련 상태 및 메서드
+ */
+
+import { MOCK_SERVERS, MOCK_STATS, MOCK_NOTIFICATIONS } from './config.js';
+
+/**
+ * 대시보드 메서드 생성
+ */
+export function createDashboardMethods() {
+ return {
+ // 대시보드 상태
+ dashboardMode: false, // true면 대시보드, false면 랜딩
+ currentView: 'servers', // 'servers' | 'stats' | 'notifications'
+
+ // 텔레그램 연동
+ telegram: {
+ isAvailable: false, // 텔레그램 환경인지
+ user: null, // 사용자 정보
+ initData: null // 검증용 데이터
+ },
+
+ // 웹 로그인 사용자 (텔레그램 로그인 위젯 사용)
+ webUser: null,
+
+ // 현재 로그인된 사용자 (텔레그램 또는 웹)
+ get currentUser() {
+ return this.telegram.user || this.webUser;
+ },
+
+ // 서버 목록
+ servers: [],
+ loadingServers: false,
+
+ // 통계
+ stats: {
+ totalCost: 0,
+ totalServers: 0,
+ runningServers: 0,
+ costBreakdown: []
+ },
+
+ // 알림
+ notifications: [],
+ unreadCount: 0,
+
+ // API 상태
+ apiLoading: false,
+ apiError: null,
+
+ // 초기화 (텔레그램 연동 + 대시보드)
+ init() {
+ if (window.Telegram?.WebApp) {
+ const tg = window.Telegram.WebApp;
+ tg.ready();
+ tg.expand();
+
+ this.telegram.isAvailable = true;
+ this.telegram.user = tg.initDataUnsafe.user || null;
+ this.telegram.initData = tg.initData;
+
+ console.log('[Telegram] Mini App initialized', {
+ user: this.telegram.user,
+ platform: tg.platform
+ });
+
+ // 텔레그램 환경이면 대시보드 모드 활성화
+ this.dashboardMode = true;
+ this.loadDashboard();
+
+ console.log('[Telegram] Dashboard mode activated', {
+ isAvailable: this.telegram.isAvailable,
+ hasUser: !!this.telegram.user
+ });
+ } else {
+ console.log('[Telegram] Running in web browser mode');
+ }
+ },
+
+ // 미니앱 전용 초기화 (/app 페이지용)
+ initMiniApp() {
+ const tg = window.Telegram?.WebApp;
+
+ // 실제 텔레그램 환경인지 확인 (initData가 있어야 진짜 텔레그램)
+ const isRealTelegram = tg && tg.initData && tg.initData.length > 0;
+
+ if (isRealTelegram) {
+ tg.ready();
+ tg.expand();
+
+ this.telegram.isAvailable = true;
+ this.telegram.user = tg.initDataUnsafe.user || null;
+ this.telegram.initData = tg.initData;
+
+ console.log('[MiniApp] Telegram environment detected', {
+ user: this.telegram.user,
+ platform: tg.platform
+ });
+
+ // 미니앱은 무조건 대시보드 로드
+ this.loadDashboard();
+ } else {
+ console.log('[MiniApp] Not in Telegram environment');
+ this.telegram.isAvailable = false;
+
+ // 웹 브라우저: localStorage에서 webUser 복원
+ const savedUser = localStorage.getItem('anvil_web_user');
+ if (savedUser) {
+ try {
+ this.webUser = JSON.parse(savedUser);
+ console.log('[MiniApp] Web user restored from localStorage:', this.webUser);
+ this.loadDashboard();
+ } catch (e) {
+ console.error('[MiniApp] Failed to parse saved user:', e);
+ localStorage.removeItem('anvil_web_user');
+ // 복원 실패시 로그인 위젯 표시
+ this.loadTelegramLoginWidget();
+ }
+ } else {
+ // 로그인 필요 - 텔레그램 로그인 위젯 로드
+ this.loadTelegramLoginWidget();
+ }
+ }
+ },
+
+ // 텔레그램 로그인 위젯 동적 로드
+ loadTelegramLoginWidget() {
+ // Alpine이 DOM을 렌더링할 시간을 주기 위해 약간 지연
+ setTimeout(() => {
+ const container = document.getElementById('telegram-login-container');
+ if (!container) {
+ console.log('[MiniApp] Login container not found, retrying...');
+ setTimeout(() => this.loadTelegramLoginWidget(), 100);
+ return;
+ }
+
+ // 이미 위젯이 있으면 스킵
+ if (container.querySelector('iframe')) {
+ console.log('[MiniApp] Login widget already loaded');
+ return;
+ }
+
+ console.log('[MiniApp] Loading Telegram Login Widget...');
+
+ // 텔레그램 위젯 스크립트 동적 생성
+ const script = document.createElement('script');
+ script.src = 'https://telegram.org/js/telegram-widget.js?22';
+ script.setAttribute('data-telegram-login', 'AnvilForgeBot');
+ script.setAttribute('data-size', 'large');
+ script.setAttribute('data-radius', '12');
+ script.setAttribute('data-onauth', 'onTelegramAuth(user)');
+ script.setAttribute('data-request-access', 'write');
+ script.async = true;
+
+ container.appendChild(script);
+ console.log('[MiniApp] Telegram Login Widget script added');
+ }, 50);
+ },
+
+ // 웹 텔레그램 로그인 핸들러
+ handleWebLogin(user) {
+ console.log('[MiniApp] Web login received:', user);
+
+ // 사용자 정보 저장
+ this.webUser = {
+ id: user.id,
+ first_name: user.first_name,
+ last_name: user.last_name || '',
+ username: user.username || '',
+ photo_url: user.photo_url || '',
+ auth_date: user.auth_date
+ };
+
+ // localStorage에 저장 (세션 유지)
+ localStorage.setItem('anvil_web_user', JSON.stringify(this.webUser));
+
+ console.log('[MiniApp] Web user logged in:', this.webUser);
+
+ // 대시보드 로드
+ this.loadDashboard();
+ },
+
+ // 로그아웃
+ logout() {
+ console.log('[MiniApp] Logging out...');
+
+ // webUser 초기화
+ this.webUser = null;
+ localStorage.removeItem('anvil_web_user');
+
+ // 서버/통계/알림 초기화
+ this.servers = [];
+ this.stats = { totalCost: 0, totalServers: 0, runningServers: 0, costBreakdown: [] };
+ this.notifications = [];
+ this.unreadCount = 0;
+
+ console.log('[MiniApp] Logged out successfully');
+ },
+
+ // 대시보드 초기 로드
+ async loadDashboard() {
+ console.log('[Dashboard] Loading dashboard data...');
+ await Promise.all([
+ this.fetchServers(),
+ this.fetchStats(),
+ this.fetchNotifications()
+ ]);
+ },
+
+ // 서버 목록 조회
+ async fetchServers() {
+ this.loadingServers = true;
+ this.apiError = null;
+
+ try {
+ // TODO: 실제 API 호출로 교체
+ // const response = await fetch('/api/servers', {
+ // headers: { 'X-Telegram-Init-Data': this.telegram.initData }
+ // });
+ // this.servers = await response.json();
+
+ // Mock 데이터 사용
+ await new Promise(resolve => setTimeout(resolve, 500));
+ this.servers = [...MOCK_SERVERS];
+ console.log('[Dashboard] Servers loaded:', this.servers.length);
+ } catch (error) {
+ console.error('[Dashboard] Failed to fetch servers:', error);
+ this.apiError = '서버 목록을 불러오는데 실패했습니다.';
+ } finally {
+ this.loadingServers = false;
+ }
+ },
+
+ // 통계 조회
+ async fetchStats() {
+ try {
+ // TODO: 실제 API 호출로 교체
+ await new Promise(resolve => setTimeout(resolve, 300));
+ this.stats = { ...MOCK_STATS };
+ console.log('[Dashboard] Stats loaded:', this.stats);
+ } catch (error) {
+ console.error('[Dashboard] Failed to fetch stats:', error);
+ }
+ },
+
+ // 알림 조회
+ async fetchNotifications() {
+ try {
+ // TODO: 실제 API 호출로 교체
+ await new Promise(resolve => setTimeout(resolve, 400));
+ this.notifications = [...MOCK_NOTIFICATIONS];
+ this.unreadCount = this.notifications.filter(n => !n.read).length;
+ console.log('[Dashboard] Notifications loaded:', this.notifications.length);
+ } catch (error) {
+ console.error('[Dashboard] Failed to fetch notifications:', error);
+ }
+ },
+
+ // 서버 시작
+ async startServer(serverId) {
+ const server = this.servers.find(s => s.id === serverId);
+ if (!server) return;
+
+ console.log('[Dashboard] Starting server:', serverId);
+ this.apiLoading = true;
+
+ try {
+ // TODO: 실제 API 호출로 교체
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ server.status = 'running';
+ this.stats.runningServers++;
+ console.log('[Dashboard] Server started:', serverId);
+ } catch (error) {
+ console.error('[Dashboard] Failed to start server:', error);
+ alert('서버를 시작하는데 실패했습니다.');
+ } finally {
+ this.apiLoading = false;
+ }
+ },
+
+ // 서버 중지
+ async stopServer(serverId) {
+ const server = this.servers.find(s => s.id === serverId);
+ if (!server) return;
+
+ console.log('[Dashboard] Stopping server:', serverId);
+ this.apiLoading = true;
+
+ try {
+ // TODO: 실제 API 호출로 교체
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ server.status = 'stopped';
+ this.stats.runningServers--;
+ console.log('[Dashboard] Server stopped:', serverId);
+ } catch (error) {
+ console.error('[Dashboard] Failed to stop server:', error);
+ alert('서버를 중지하는데 실패했습니다.');
+ } finally {
+ this.apiLoading = false;
+ }
+ },
+
+ // 서버 삭제
+ async deleteServer(serverId) {
+ if (!confirm('정말로 이 서버를 삭제하시겠습니까?')) return;
+
+ console.log('[Dashboard] Deleting server:', serverId);
+ this.apiLoading = true;
+
+ try {
+ // TODO: 실제 API 호출로 교체
+ await new Promise(resolve => setTimeout(resolve, 800));
+
+ const serverIndex = this.servers.findIndex(s => s.id === serverId);
+ if (serverIndex !== -1) {
+ const deletedServer = this.servers[serverIndex];
+ this.servers.splice(serverIndex, 1);
+
+ // 통계 업데이트
+ this.stats.totalServers--;
+ this.stats.totalCost -= deletedServer.cost;
+ if (deletedServer.status === 'running') {
+ this.stats.runningServers--;
+ }
+
+ console.log('[Dashboard] Server deleted:', serverId);
+ }
+ } catch (error) {
+ console.error('[Dashboard] Failed to delete server:', error);
+ alert('서버를 삭제하는데 실패했습니다.');
+ } finally {
+ this.apiLoading = false;
+ }
+ },
+
+ // 모든 알림 읽음 처리
+ markAllRead() {
+ this.notifications.forEach(n => n.read = true);
+ this.unreadCount = 0;
+ console.log('[Dashboard] All notifications marked as read');
+ },
+
+ // 뷰 전환
+ switchView(view) {
+ this.currentView = view;
+ console.log('[Dashboard] View switched to:', view);
+ }
+ };
+}
diff --git a/js/pricing.js b/js/pricing.js
new file mode 100644
index 0000000..3f19e7d
--- /dev/null
+++ b/js/pricing.js
@@ -0,0 +1,502 @@
+/**
+ * Pricing Table Component
+ * API 연동 가격표 (아시아 전용, 캐싱 적용)
+ */
+
+/**
+ * 가격표 컴포넌트
+ */
+export function pricingTable() {
+ // 캐시 키 및 유효 시간 (1시간)
+ const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴)
+ const CACHE_TTL = 60 * 60 * 1000; // 1시간
+
+ // Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul
+ const FALLBACK_DATA = [
+ { id: 'f1', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 5 } },
+ { id: 'f2', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 2, JP' }, pricing: { monthly_price: 24 } },
+ { id: 'f3', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 5 } },
+ { id: 'f4', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Tokyo 3, JP' }, pricing: { monthly_price: 24 } },
+ { id: 'f5', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 5 } },
+ { id: 'f6', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Osaka, JP', region_code: 'jp-osa' }, pricing: { monthly_price: 24 } },
+ { id: 'f7', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 5 } },
+ { id: 'f8', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'linode' }, region: { region_name: 'Singapore, SG' }, pricing: { monthly_price: 24 } },
+ { id: 'f9', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 6 } },
+ { id: 'f10', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, provider: { name: 'vultr' }, region: { region_name: 'Seoul, KR' }, pricing: { monthly_price: 24 } },
+ ];
+
+ return {
+ // 필터 상태
+ sortBy: 'vcpu', // 'vcpu' | 'memory' | 'price'
+ sortOrder: 'asc', // 'asc' | 'desc'
+ selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
+ selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭)
+ selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭)
+
+ // 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU
+ cities: [
+ { id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' },
+ { id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' },
+ { id: 'gpu-japan', name: 'GPU Japan', flag: '🇯🇵', provider: 'linode', isGpu: true },
+ { id: 'gpu-korea', name: 'GPU Korea', flag: '🇰🇷', provider: 'vultr', isGpu: true },
+ ],
+
+ // 글로벌(Linode) 서브탭 (인스턴스 타입별)
+ globalTypes: [
+ { id: 'standard', name: 'Standard', tooltip: '공유 CPU · 1-32 vCPU · 1-192GB RAM\n버스트 가능한 CPU로 비용 효율적\n웹서버, 개발환경에 적합' },
+ { id: 'dedicated', name: 'Dedicated', tooltip: '전용 CPU · 4-64 vCPU · 8-512GB RAM\n100% CPU 자원 보장\nCI/CD, 게임서버, 고부하 작업에 적합' },
+ { id: 'premium', name: 'Premium', tooltip: 'AMD EPYC 9004 · 1-64 vCPU · 2-512GB RAM\nDDR5 메모리 · NVMe 스토리지\n최신 세대 고성능 컴퓨팅' },
+ { id: 'highmem', name: 'High Memory', tooltip: '고밀도 메모리 · 2-16 vCPU · 24-300GB RAM\nvCPU당 RAM 비율 최대화\n데이터베이스, 캐싱, 분석 워크로드' },
+ ],
+
+ // 서울 서브탭 (인스턴스 타입별)
+ seoulTypes: [
+ { id: 'vc2', name: 'Cloud Compute', tooltip: '일반 SSD · 1-24 vCPU · 1-96GB RAM\n가성비 좋은 범용 인스턴스\n웹호스팅, 소규모 앱에 적합' },
+ { id: 'vhf', name: 'High Frequency', tooltip: 'NVMe SSD · 3GHz+ CPU · 1-12 vCPU\n고클럭 프로세서로 단일 스레드 성능 극대화\n게임서버, 실시간 처리에 적합' },
+ { id: 'vhp', name: 'High Performance', tooltip: 'AMD EPYC · 1-16 vCPU · 1-64GB RAM\n전용 CPU 코어 · NVMe 스토리지\n고성능 컴퓨팅, ML 추론에 적합' },
+ { id: 'voc', name: 'Optimized', tooltip: '특화 인스턴스 · 다양한 구성\nCPU/메모리/스토리지 최적화 버전\n특정 워크로드에 맞춤 선택' },
+ { id: 'vx1', name: 'Extreme', tooltip: '초고밀도 · 4-24 vCPU · 96-384GB RAM\n고메모리 비율 인스턴스\n대규모 DB, 인메모리 캐시에 적합' },
+ ],
+
+ // 데이터 상태
+ instances: [],
+ filteredInstances: [],
+ loading: false,
+ error: null,
+ lastUpdate: null,
+ fromCache: false,
+
+ // 지원 리전 패턴 (Tokyo, Osaka, Singapore = Linode / Seoul = Vultr)
+ supportedRegions: ['Tokyo', 'Osaka', 'Singapore', 'Seoul', 'ap-northeast', 'ap-southeast', 'jp-osa'],
+
+ // 초기화
+ async init() {
+ await this.loadInstances();
+ },
+
+ // 캐시에서 로드 또는 API 호출
+ async loadInstances() {
+ // 캐시 확인
+ const cached = this.getCache();
+ if (cached) {
+ console.log('[PricingTable] Using cached data');
+ this.instances = this.filterAndSort(cached.instances);
+ this.filteredInstances = this.applyFilters(this.instances);
+ this.lastUpdate = new Date(cached.timestamp);
+ this.fromCache = true;
+ return;
+ }
+
+ // 캐시 없으면 API 호출
+ await this.fetchFromApi();
+ },
+
+ // 캐시 조회
+ getCache() {
+ try {
+ const data = localStorage.getItem(CACHE_KEY);
+ if (!data) return null;
+
+ const parsed = JSON.parse(data);
+ const age = Date.now() - parsed.timestamp;
+
+ if (age > CACHE_TTL) {
+ console.log('[PricingTable] Cache expired');
+ localStorage.removeItem(CACHE_KEY);
+ return null;
+ }
+
+ return parsed;
+ } catch (e) {
+ console.error('[PricingTable] Cache read error:', e);
+ return null;
+ }
+ },
+
+ // 캐시 저장
+ setCache(instances) {
+ try {
+ const data = {
+ instances,
+ timestamp: Date.now()
+ };
+ localStorage.setItem(CACHE_KEY, JSON.stringify(data));
+ console.log('[PricingTable] Cache saved');
+ } catch (e) {
+ console.error('[PricingTable] Cache write error:', e);
+ }
+ },
+
+ // API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음)
+ async fetchFromApi() {
+ this.loading = true;
+ this.error = null;
+ this.fromCache = false;
+
+ try {
+ // D1 직접 조회 엔드포인트 사용 (rate limit 없음)
+ const response = await fetch('/api/pricing');
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ throw new Error(data.error || `HTTP ${response.status}`);
+ }
+
+ const instances = data.instances || [];
+ console.log('[PricingTable] Fetched', instances.length, 'instances from D1');
+ console.log('[PricingTable] Region counts:', data.region_counts);
+
+ if (instances.length > 0) {
+ // 캐시에 저장
+ this.setCache(instances);
+
+ this.instances = this.filterAndSort(instances);
+ this.filteredInstances = this.applyFilters(this.instances);
+ this.lastUpdate = new Date();
+
+ console.log('[PricingTable] Loaded', this.instances.length, 'instances (Linode: JP/SG, Vultr: KR)');
+ } else {
+ throw new Error('인스턴스 데이터가 없습니다.');
+ }
+ } catch (err) {
+ console.error('[PricingTable] Error:', err);
+
+ // 에러 시 만료된 캐시 사용
+ const expiredCache = localStorage.getItem(CACHE_KEY);
+ if (expiredCache) {
+ try {
+ const parsed = JSON.parse(expiredCache);
+ this.instances = this.filterAndSort(parsed.instances);
+ this.filteredInstances = this.applyFilters(this.instances);
+ this.lastUpdate = new Date(parsed.timestamp);
+ this.fromCache = true;
+ this.error = null;
+ console.log('[PricingTable] Using expired cache as fallback');
+ return;
+ } catch (e) {
+ // 캐시 파싱 실패, fallback 사용
+ }
+ }
+
+ // 캐시도 없으면 fallback 데이터 사용
+ console.log('[PricingTable] Using fallback data');
+ this.instances = this.filterAndSort(FALLBACK_DATA);
+ this.filteredInstances = this.applyFilters(this.instances);
+ this.fromCache = true;
+ this.error = null;
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ // 필터 및 정렬 적용 (모든 인스턴스 표시)
+ filterAndSort(instances) {
+ let filtered = [...instances];
+
+ // 정렬 적용
+ filtered.sort((a, b) => {
+ switch (this.sortBy) {
+ case 'price':
+ return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
+ case 'vcpu':
+ return (b.vcpu || 0) - (a.vcpu || 0);
+ case 'memory':
+ return (b.memory_mb || 0) - (a.memory_mb || 0);
+ default:
+ return (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
+ }
+ });
+
+ return filtered;
+ },
+
+ // 리전 이름 정규화 (Tokyo 2, JP -> Tokyo)
+ normalizeRegion(regionName) {
+ const name = regionName.toLowerCase();
+ if (name.includes('osaka') || name.includes('jp-osa')) return 'Osaka';
+ if (name.includes('tokyo')) return 'Tokyo';
+ if (name.includes('singapore')) return 'Singapore';
+ if (name.includes('seoul')) return 'Seoul';
+ return regionName.split(',')[0].trim();
+ },
+
+ // 필터 변경 핸들러 (도시 필터 + 정렬 적용)
+ onFilterChange() {
+ this.filteredInstances = this.applyFilters(this.instances);
+ },
+
+ // 정렬 토글 (같은 컬럼 클릭 시 오름/내림 전환)
+ toggleSort(column) {
+ if (this.sortBy === column) {
+ // 같은 컬럼: 방향 토글
+ this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
+ } else {
+ // 다른 컬럼: 기본 방향 설정
+ this.sortBy = column;
+ // vcpu, memory는 내림차순이 기본, price는 오름차순이 기본
+ this.sortOrder = column === 'price' ? 'asc' : 'desc';
+ }
+ this.onFilterChange();
+ },
+
+ // 도시 필터 + 정렬 적용
+ applyFilters(instances) {
+ let filtered = [...instances];
+
+ // 도시 필터 (항상 적용)
+ filtered = filtered.filter(inst => {
+ const regionName = (inst.region?.region_name || '').toLowerCase();
+ const regionCode = (inst.region?.region_code || '').toLowerCase();
+ const instId = (inst.id || '').toLowerCase();
+ const hasGpu = inst.has_gpu || inst.gpu_count > 0;
+ switch (this.selectedCity) {
+ case 'global':
+ // Global: Tokyo 2 + 서브탭(Standard/Dedicated/Premium/HighMem) 필터링, GPU 제외
+ if (!regionName.includes('tokyo 2') || hasGpu) return false;
+ switch (this.selectedGlobalType) {
+ case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard');
+ case 'dedicated': return instId.startsWith('g6-dedicated');
+ case 'premium': return instId.startsWith('g7-premium');
+ case 'highmem': return instId.startsWith('g7-highmem');
+ default: return false;
+ }
+ case 'seoul':
+ // 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외
+ const isSeoul = regionName.includes('seoul');
+ if (!isSeoul || !instId.startsWith(this.selectedSeoulType + '-') || hasGpu) return false;
+ // vhp: AMD/Intel 중복 방지 → AMD만 표시 (Intel은 레거시)
+ if (this.selectedSeoulType === 'vhp' && instId.endsWith('-intel')) return false;
+ return true;
+ case 'gpu-japan':
+ // 일본 GPU (도쿄만, 중복 방지)
+ return regionName.includes('tokyo 2') && hasGpu;
+ case 'gpu-korea':
+ // 한국 GPU
+ return regionName.includes('seoul') && hasGpu;
+ default: return false;
+ }
+ });
+
+ // 정렬 (sortOrder 반영, 보조 정렬: 가격)
+ const order = this.sortOrder === 'asc' ? 1 : -1;
+ filtered.sort((a, b) => {
+ let diff = 0;
+ switch (this.sortBy) {
+ case 'price':
+ diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
+ break;
+ case 'vcpu':
+ diff = (a.vcpu || 0) - (b.vcpu || 0);
+ if (diff === 0) {
+ diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
+ }
+ break;
+ case 'memory':
+ diff = (a.memory_mb || 0) - (b.memory_mb || 0);
+ if (diff === 0) {
+ diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
+ }
+ break;
+ default:
+ diff = (a.vcpu || 0) - (b.vcpu || 0);
+ }
+ return diff * order;
+ });
+
+ return filtered;
+ },
+
+ // 도시별 인스턴스 수 조회
+ getInstanceCountByCity(cityId) {
+ return this.instances.filter(inst => {
+ const regionName = (inst.region?.region_name || '').toLowerCase();
+ const hasGpu = inst.has_gpu || inst.gpu_count > 0;
+ switch (cityId) {
+ case 'global':
+ return regionName.includes('tokyo 2') && !hasGpu;
+ case 'seoul':
+ return regionName.includes('seoul') && !hasGpu;
+ case 'gpu-japan':
+ return regionName.includes('tokyo 2') && hasGpu;
+ case 'gpu-korea':
+ return regionName.includes('seoul') && hasGpu;
+ default: return false;
+ }
+ }).length;
+ },
+
+ // 서울 서브탭별 인스턴스 수 조회
+ getSeoulTypeCount(typeId) {
+ return this.instances.filter(inst => {
+ const regionName = (inst.region?.region_name || '').toLowerCase();
+ const instId = (inst.id || '').toLowerCase();
+ return regionName.includes('seoul') && instId.startsWith(typeId + '-');
+ }).length;
+ },
+
+ // 글로벌 서브탭별 인스턴스 수 조회
+ getGlobalTypeCount(typeId) {
+ return this.instances.filter(inst => {
+ const regionName = (inst.region?.region_name || '').toLowerCase();
+ const instId = (inst.id || '').toLowerCase();
+ const hasGpu = inst.has_gpu || inst.gpu_count > 0;
+ if (!regionName.includes('tokyo 2') || hasGpu) return false;
+ switch (typeId) {
+ case 'standard': return instId.startsWith('g6-nanode') || instId.startsWith('g6-standard');
+ case 'dedicated': return instId.startsWith('g6-dedicated');
+ case 'premium': return instId.startsWith('g7-premium');
+ case 'highmem': return instId.startsWith('g7-highmem');
+ default: return false;
+ }
+ }).length;
+ },
+
+ // 강제 새로고침 (캐시 삭제 후 API 호출)
+ async forceRefresh() {
+ localStorage.removeItem(CACHE_KEY);
+ await this.fetchFromApi();
+ },
+
+ // 가격 포맷 (USD)
+ formatUsd(price) {
+ if (price == null) return '-';
+ return '$' + price.toFixed(2);
+ },
+
+ // 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
+ formatKrw(krwPrice) {
+ if (krwPrice == null) return '-';
+ return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
+ },
+
+ // 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
+ formatKrwHourly(krwPrice) {
+ if (krwPrice == null) return '-';
+ return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
+ },
+
+ // 메모리 포맷
+ formatMemory(mb) {
+ if (mb >= 1024) {
+ return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
+ }
+ return mb + ' MB';
+ },
+
+ // 스토리지 포맷
+ formatStorage(gb) {
+ if (gb == null) return '-';
+ if (gb >= 1000) {
+ return (gb / 1000).toFixed(1) + ' TB';
+ }
+ return gb + ' GB';
+ },
+
+ // 트래픽 포맷
+ formatTransfer(tb) {
+ if (tb == null) return '-';
+ if (tb >= 1) {
+ return tb.toFixed(tb % 1 === 0 ? 0 : 1) + ' TB';
+ }
+ return Math.round(tb * 1024) + ' GB';
+ },
+
+ // 프로바이더 색상
+ getProviderColor(provider) {
+ const colors = {
+ 'linode': 'bg-green-500/20 text-green-400',
+ 'vultr': 'bg-blue-500/20 text-blue-400',
+ 'aws': 'bg-orange-500/20 text-orange-400',
+ };
+ return colors[provider?.toLowerCase()] || 'bg-slate-500/20 text-slate-400';
+ },
+
+ // 리전 플래그 이모지
+ getRegionFlag(regionName) {
+ const flags = {
+ 'tokyo': '🇯🇵', 'osaka': '🇯🇵', 'japan': '🇯🇵',
+ 'singapore': '🇸🇬',
+ 'hong kong': '🇭🇰',
+ 'seoul': '🇰🇷', 'korea': '🇰🇷',
+ 'mumbai': '🇮🇳', 'bangalore': '🇮🇳', 'india': '🇮🇳',
+ 'sydney': '🇦🇺', 'australia': '🇦🇺',
+ 'amsterdam': '🇳🇱',
+ 'frankfurt': '🇩🇪', 'germany': '🇩🇪',
+ 'london': '🇬🇧', 'uk': '🇬🇧',
+ 'paris': '🇫🇷', 'france': '🇫🇷',
+ 'us': '🇺🇸', 'america': '🇺🇸', 'atlanta': '🇺🇸', 'dallas': '🇺🇸',
+ 'chicago': '🇺🇸', 'miami': '🇺🇸', 'new york': '🇺🇸', 'seattle': '🇺🇸',
+ 'los angeles': '🇺🇸', 'silicon valley': '🇺🇸'
+ };
+
+ const lower = (regionName || '').toLowerCase();
+ for (const [key, flag] of Object.entries(flags)) {
+ if (lower.includes(key)) return flag;
+ }
+ return '🌐';
+ },
+
+ // 마지막 업데이트 시간 표시
+ getLastUpdateText() {
+ if (!this.lastUpdate) return '';
+ const now = new Date();
+ const diff = Math.floor((now - this.lastUpdate) / 1000);
+ if (diff < 60) return '방금 전';
+ if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
+ return this.lastUpdate.toLocaleTimeString('ko-KR');
+ },
+
+ // 인스턴스 선택 상태
+ selectedInstance: null,
+
+ // 모달 상태
+ showInstanceModal: false,
+ selectedInstanceDetail: null,
+
+ // 클립보드 복사 상태
+ copiedToClipboard: false,
+
+ // 인스턴스 선택 핸들러
+ selectInstance(inst) {
+ // 같은 인스턴스 다시 클릭하면 선택 해제 및 모달 닫기
+ if (this.selectedInstance?.id === inst.id &&
+ this.selectedInstance?.region?.region_code === inst.region?.region_code) {
+ this.selectedInstance = null;
+ this.showInstanceModal = false;
+ this.selectedInstanceDetail = null;
+ return;
+ }
+
+ this.selectedInstance = inst;
+ this.selectedInstanceDetail = inst;
+ this.showInstanceModal = true;
+ },
+
+ // 모달 닫기
+ closeInstanceModal() {
+ this.showInstanceModal = false;
+ },
+
+ // 인스턴스 스펙 복사
+ copyInstanceSpec() {
+ const inst = this.selectedInstanceDetail;
+ if (!inst) return;
+
+ const spec = `vCPU: ${inst.vcpu}, RAM: ${this.formatMemory(inst.memory_mb)}, Storage: ${this.formatStorage(inst.storage_gb)}, 월 ${this.formatKrw(inst.pricing?.monthly_price_krw)}`;
+
+ navigator.clipboard.writeText(spec).then(() => {
+ console.log('[PricingTable] Copied to clipboard:', spec);
+ this.copiedToClipboard = true;
+ setTimeout(() => { this.copiedToClipboard = false; }, 2000);
+ }).catch(err => {
+ console.error('[PricingTable] Failed to copy:', err);
+ });
+ },
+
+ // 텔레그램 링크 가져오기
+ getInstanceTelegramLink() {
+ if (!this.selectedInstanceDetail) return '#';
+ return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`;
+ }
+ };
+}
diff --git a/js/utils.js b/js/utils.js
new file mode 100644
index 0000000..8575ff5
--- /dev/null
+++ b/js/utils.js
@@ -0,0 +1,132 @@
+/**
+ * Utility Functions
+ * 유틸리티 함수 모음
+ */
+
+/**
+ * 가격 포맷팅 (한국 원화)
+ */
+export function formatPrice(price) {
+ return '₩' + price.toLocaleString('ko-KR');
+}
+
+/**
+ * 탭 전환 (n8n/Terraform)
+ */
+export function switchTab(tab) {
+ const btnN8n = document.getElementById('btn-n8n');
+ const btnTf = document.getElementById('btn-tf');
+ const panelN8n = document.getElementById('panel-n8n');
+ const panelTf = document.getElementById('panel-tf');
+
+ // Null checks for DOM elements
+ if (!btnN8n || !btnTf || !panelN8n || !panelTf) return;
+
+ if (tab === 'n8n') {
+ // Update ARIA states for n8n tab
+ btnN8n.setAttribute('aria-selected', 'true');
+ btnN8n.setAttribute('tabindex', '0');
+ btnTf.setAttribute('aria-selected', 'false');
+ btnTf.setAttribute('tabindex', '-1');
+
+ // Update classes
+ btnN8n.className = 'px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-bold transition shadow-lg shadow-purple-500/20';
+ btnTf.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
+ panelN8n.classList.remove('hidden');
+ panelTf.classList.add('hidden');
+ } else {
+ // Update ARIA states for Terraform tab
+ btnTf.setAttribute('aria-selected', 'true');
+ btnTf.setAttribute('tabindex', '0');
+ btnN8n.setAttribute('aria-selected', 'false');
+ btnN8n.setAttribute('tabindex', '-1');
+
+ // Update classes
+ btnN8n.className = 'px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-sm font-bold border border-slate-700 hover:text-white transition';
+ btnTf.className = 'px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-bold transition shadow-lg shadow-blue-500/20';
+ panelN8n.classList.add('hidden');
+ panelTf.classList.remove('hidden');
+ }
+}
+
+/**
+ * 실시간 Ping 시뮬레이션
+ */
+export function updatePing() {
+ const regions = [
+ { id: 'ping-kr', base: 2, variance: 2 },
+ { id: 'ping-jp', base: 35, variance: 5 },
+ { id: 'ping-hk', base: 45, variance: 8 },
+ { id: 'ping-sg', base: 65, variance: 10 }
+ ];
+
+ regions.forEach(region => {
+ const el = document.getElementById(region.id);
+ if (el) {
+ const jitter = Math.floor(Math.random() * region.variance) - (region.variance / 2);
+ let val = Math.max(1, Math.floor(region.base + jitter));
+ el.innerText = val;
+ }
+ });
+}
+
+// Ping 업데이트 시작 (visibility-aware)
+let pingInterval;
+
+export function startPingUpdates() {
+ if (!pingInterval) {
+ pingInterval = setInterval(updatePing, 2000);
+ }
+}
+
+export function stopPingUpdates() {
+ if (pingInterval) {
+ clearInterval(pingInterval);
+ pingInterval = null;
+ }
+}
+
+// Visibility change handler
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ stopPingUpdates();
+ } else {
+ startPingUpdates();
+ }
+});
+
+// Initial start
+startPingUpdates();
+
+// Tab switching with event listeners and keyboard navigation
+document.addEventListener('DOMContentLoaded', () => {
+ const btnN8n = document.getElementById('btn-n8n');
+ const btnTf = document.getElementById('btn-tf');
+
+ if (btnN8n && btnTf) {
+ // Click event listeners
+ btnN8n.addEventListener('click', () => switchTab('n8n'));
+ btnTf.addEventListener('click', () => switchTab('tf'));
+
+ // Keyboard navigation (Arrow keys)
+ [btnN8n, btnTf].forEach(btn => {
+ btn.addEventListener('keydown', (e) => {
+ const currentTab = btn.getAttribute('data-tab');
+
+ if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ if (currentTab === 'n8n') {
+ btnTf.focus();
+ switchTab('tf');
+ }
+ } else if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ if (currentTab === 'tf') {
+ btnN8n.focus();
+ switchTab('n8n');
+ }
+ }
+ });
+ });
+ }
+});
diff --git a/js/wizard.js b/js/wizard.js
new file mode 100644
index 0000000..5890ee8
--- /dev/null
+++ b/js/wizard.js
@@ -0,0 +1,199 @@
+/**
+ * Server Recommendation Wizard
+ * 서버 추천 마법사 로직
+ */
+
+import { WIZARD_CONFIG } from './config.js';
+
+/**
+ * 서버 추천 로직 (룰 기반)
+ * @param {string[]} selectedStacks - 선택된 스택 ID 목록
+ * @param {string} scale - 규모 키 (personal, small, medium, large)
+ * @returns {Object} 추천 결과 { economy, recommended, performance }
+ */
+export function calculateRecommendation(selectedStacks, scale) {
+ // 기본 요구사항 계산
+ let totalRam = 0;
+ let totalCpu = 0;
+ let needsGpu = false;
+
+ selectedStacks.forEach(stackId => {
+ // 모든 카테고리에서 스택 찾기
+ for (const category of Object.values(WIZARD_CONFIG.stacks)) {
+ const stack = category.find(s => s.id === stackId);
+ if (stack) {
+ totalRam += stack.ram;
+ totalCpu += stack.cpu;
+ if (stack.gpu) needsGpu = true;
+ break;
+ }
+ }
+ });
+
+ // 규모에 따른 배율 적용 (이제 객체 형태)
+ const scaleConfig = WIZARD_CONFIG.scales[scale];
+ const multiplier = scaleConfig?.multiplier || 1;
+
+ totalRam = Math.ceil(totalRam * multiplier);
+ totalCpu = Math.ceil(totalCpu * multiplier);
+
+ // 최소/최대 제한
+ totalRam = Math.max(1024, Math.min(totalRam, 65536));
+ totalCpu = Math.max(1, Math.min(totalCpu, 16));
+
+ // 가격 계산 (대략적인 추정, 실제로는 API 호출 필요)
+ const estimatePrice = (cpu, ram) => {
+ // 기본 가격: vCPU당 $5, GB당 $2 (월간)
+ return cpu * 5 + (ram / 1024) * 2;
+ };
+
+ // 추천 플랜 결정 (객체 형태로 반환)
+ return {
+ economy: {
+ cpu: Math.max(1, Math.floor(totalCpu * 0.7)),
+ ram: Math.max(1024, Math.floor(totalRam * 0.7)),
+ price: estimatePrice(Math.max(1, Math.floor(totalCpu * 0.7)), Math.max(1024, Math.floor(totalRam * 0.7)))
+ },
+ recommended: {
+ cpu: totalCpu,
+ ram: totalRam,
+ price: estimatePrice(totalCpu, totalRam)
+ },
+ performance: {
+ cpu: Math.min(16, Math.ceil(totalCpu * 1.5)),
+ ram: Math.min(65536, Math.ceil(totalRam * 1.5)),
+ price: estimatePrice(Math.min(16, Math.ceil(totalCpu * 1.5)), Math.min(65536, Math.ceil(totalRam * 1.5)))
+ },
+ needsGpu,
+ totalStacks: selectedStacks.length,
+ scale
+ };
+}
+
+/**
+ * 마법사 앱 상태 및 메서드
+ * anvilApp에서 사용할 마법사 관련 로직
+ */
+export function createWizardMethods() {
+ return {
+ // 마법사 상태
+ wizardOpen: false,
+ wizardCurrentStep: 0, // 0: purpose, 1: stack, 2: scale, 3: result
+ wizardPurpose: null,
+ wizardStacks: [], // 선택된 스택들 (다중 선택)
+ wizardScale: null,
+ wizardRecommendations: null,
+ wizardSelectedPlan: null,
+
+ // 마법사 데이터 접근자
+ get wizardPurposes() { return WIZARD_CONFIG.purposes; },
+ get wizardScales() { return WIZARD_CONFIG.scales; },
+
+ // 현재 용도에 맞는 스택 목록
+ get wizardAvailableStacks() {
+ if (!this.wizardPurpose) return [];
+ return WIZARD_CONFIG.stacks[this.wizardPurpose] || [];
+ },
+
+ // 마법사 열기
+ openWizard() {
+ this.wizardOpen = true;
+ this.wizardCurrentStep = 0;
+ this.wizardPurpose = null;
+ this.wizardStacks = [];
+ this.wizardScale = null;
+ this.wizardRecommendations = null;
+ this.wizardSelectedPlan = null;
+ },
+
+ // 마법사 닫기
+ closeWizard() {
+ this.wizardOpen = false;
+ },
+
+ // 용도 선택
+ selectWizardPurpose(purposeId) {
+ this.wizardPurpose = purposeId;
+ this.wizardStacks = []; // 스택 초기화
+ this.wizardCurrentStep = 1;
+ },
+
+ // 스택 토글 (다중 선택)
+ toggleWizardStack(stackId) {
+ const idx = this.wizardStacks.indexOf(stackId);
+ if (idx === -1) {
+ this.wizardStacks.push(stackId);
+ } else {
+ this.wizardStacks.splice(idx, 1);
+ }
+ },
+
+ // 스택 선택 완료 → 규모 선택으로
+ confirmWizardStacks() {
+ if (this.wizardStacks.length === 0) {
+ alert('최소 1개의 기술 스택을 선택해주세요.');
+ return;
+ }
+ this.wizardCurrentStep = 2;
+ },
+
+ // 규모 선택 → 추천 결과로
+ selectWizardScale(scaleId) {
+ this.wizardScale = scaleId;
+ this.wizardRecommendations = calculateRecommendation(this.wizardStacks, scaleId);
+ this.wizardCurrentStep = 3;
+ },
+
+ // 이전 단계로
+ wizardGoBack() {
+ if (this.wizardCurrentStep > 0) {
+ this.wizardCurrentStep--;
+ }
+ },
+
+ // 추천 플랜 선택 → 텔레그램으로 연결
+ selectWizardPlan(tierKey) {
+ const plan = this.wizardRecommendations[tierKey];
+ if (!plan) return;
+
+ this.wizardSelectedPlan = plan;
+
+ // 텔레그램 봇으로 연결 (선택한 사양 정보 포함)
+ window.open(`https://t.me/AnvilForgeBot?start=wizard_${tierKey}_${plan.cpu}c_${plan.ram}m`, '_blank');
+
+ this.closeWizard();
+ },
+
+ // RAM 포맷팅
+ formatWizardRam(mb) {
+ if (mb >= 1024) {
+ return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
+ }
+ return mb + ' MB';
+ },
+
+ // 현재 용도에 맞는 스택 그룹 반환
+ getWizardStacksByPurpose() {
+ if (!this.wizardPurpose) return {};
+ const purposeStacks = WIZARD_CONFIG.stacks[this.wizardPurpose] || [];
+ // 카테고리별로 그룹핑
+ const grouped = {};
+ for (const stack of purposeStacks) {
+ if (!grouped[stack.category]) {
+ grouped[stack.category] = [];
+ }
+ grouped[stack.category].push(stack);
+ }
+ return grouped;
+ },
+
+ // 스택 ID로 이름 찾기
+ getWizardStackName(stackId) {
+ for (const purposeStacks of Object.values(WIZARD_CONFIG.stacks)) {
+ const stack = purposeStacks.find(s => s.id === stackId);
+ if (stack) return stack.name;
+ }
+ return stackId;
+ }
+ };
+}