/** * Pricing Table Component * Anvil 가격표 (D1 직접 조회) */ /** * 가격표 컴포넌트 */ export function pricingTable() { // 캐시 키 및 유효 시간 (1시간) const CACHE_KEY = 'anvil_pricing_cache_v6'; // v6: D1 API 형식 호환 const CACHE_TTL = 60 * 60 * 1000; // 1시간 // Fallback 데이터 (API 실패 시 사용) const FALLBACK_DATA = [ { id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } }, { id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } }, { id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, category: 'vm', provider: 'linode', region: { display_name: 'Tokyo 1', country_code: 'JP' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } }, { id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } }, { id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, category: 'vm', provider: 'vultr', region: { display_name: 'Seoul 1', country_code: 'KR' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } }, ]; return { // 필터 상태 sortBy: 'price', // 'vcpu' | 'memory' | 'price' sortOrder: 'asc', // 'asc' | 'desc' selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea' selectedSeoulType: 'all', // 'all' (서브탭 단순화) selectedGlobalType: 'all', // 'all' (서브탭 단순화) // 도시 목록 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 }, ], // 글로벌 서브탭 (단순화) globalTypes: [ { id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n도쿄, 오사카, 싱가폴 리전' }, ], // 서울 서브탭 (단순화) seoulTypes: [ { id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n서울 리전' }, ], // 데이터 상태 instances: [], filteredInstances: [], loading: false, error: null, lastUpdate: null, fromCache: false, // 초기화 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에서 인스턴스 가져오기 async fetchFromApi() { this.loading = true; this.error = null; this.fromCache = false; try { 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'); } 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 데이터 사용 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; }, // 리전 이름에서 도시 추출 getRegionCity(displayName) { const name = (displayName || '').toLowerCase(); if (name.includes('tokyo')) return 'tokyo'; if (name.includes('osaka')) return 'osaka'; if (name.includes('singapore')) return 'singapore'; if (name.includes('seoul')) return 'seoul'; return name; }, // 필터 변경 핸들러 onFilterChange() { this.filteredInstances = this.applyFilters(this.instances); }, // 정렬 토글 toggleSort(column) { if (this.sortBy === column) { this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.sortBy = column; this.sortOrder = column === 'price' ? 'asc' : 'desc'; } this.onFilterChange(); }, // 도시 필터 + 정렬 적용 applyFilters(instances) { let filtered = [...instances]; // 도시 필터 filtered = filtered.filter(inst => { const displayName = (inst.region?.display_name || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.category === 'gpu'; switch (this.selectedCity) { case 'global': // Global: 도쿄/오사카/싱가폴 (GPU 제외) const isGlobal = displayName.includes('tokyo') || displayName.includes('osaka') || displayName.includes('singapore'); return isGlobal && !hasGpu; case 'seoul': // 서울 (GPU 제외) return displayName.includes('seoul') && !hasGpu; case 'gpu-japan': // 일본 GPU return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu; case 'gpu-korea': // 한국 GPU return displayName.includes('seoul') && hasGpu; default: return false; } }); // 정렬 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 displayName = (inst.region?.display_name || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.category === 'gpu'; switch (cityId) { case 'global': const isGlobal = displayName.includes('tokyo') || displayName.includes('osaka') || displayName.includes('singapore'); return isGlobal && !hasGpu; case 'seoul': return displayName.includes('seoul') && !hasGpu; case 'gpu-japan': return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu; case 'gpu-korea': return displayName.includes('seoul') && hasGpu; default: return false; } }).length; }, // 서울 서브탭별 인스턴스 수 조회 getSeoulTypeCount(typeId) { if (typeId === 'all') { return this.instances.filter(inst => { const displayName = (inst.region?.display_name || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.category === 'gpu'; return displayName.includes('seoul') && !hasGpu; }).length; } return 0; }, // 글로벌 서브탭별 인스턴스 수 조회 getGlobalTypeCount(typeId) { if (typeId === 'all') { return this.instances.filter(inst => { const displayName = (inst.region?.display_name || '').toLowerCase(); const hasGpu = inst.has_gpu || inst.category === 'gpu'; const isGlobal = displayName.includes('tokyo') || displayName.includes('osaka') || displayName.includes('singapore'); return isGlobal && !hasGpu; }).length; } return 0; }, // 강제 새로고침 async forceRefresh() { localStorage.removeItem(CACHE_KEY); await this.fetchFromApi(); }, // 가격 포맷 (USD) formatUsd(price) { if (price == null) return '-'; return '$' + price.toFixed(2); }, // 가격 포맷 (KRW) formatKrw(krwPrice) { if (krwPrice == null) return '-'; return '₩' + Math.round(krwPrice).toLocaleString('ko-KR'); }, // 시간당 가격 포맷 (KRW) formatKrwHourly(krwPrice) { if (krwPrice == null) return '-'; return '₩' + Math.round(krwPrice).toLocaleString('ko-KR'); }, // 메모리 포맷 formatMemory(mb) { if (mb == null) return '-'; 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'; }, // 리전 플래그 이모지 getRegionFlag(regionName) { const flags = { 'tokyo': '🇯🇵', 'osaka': '🇯🇵', 'japan': '🇯🇵', 'singapore': '🇸🇬', 'seoul': '🇰🇷', 'korea': '🇰🇷', }; 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?.display_name === inst.region?.display_name) { 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 '#'; const region = this.selectedInstanceDetail.region?.display_name || ''; return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}_${encodeURIComponent(region.toLowerCase().replace(/\s+/g, '-'))}`; } }; }