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 @@ + + + A + 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; + } + }; +}