feat: add server recommendation wizard

- Add 4-step wizard (purpose → stack → scale → recommendation)
- Rule-based recommendation engine (WIZARD_CONFIG)
- 6 purpose categories: web, game, AI/ML, dev, database, other
- Multiple stack selection with category grouping
- 4 scale options with RAM/CPU multipliers
- 3-tier recommendations: economy, recommended, performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-23 12:44:27 +09:00
parent fb655d73cc
commit f93311ffe2
2 changed files with 543 additions and 12 deletions

337
app.js
View File

@@ -229,6 +229,139 @@ function formatPrice(price) {
return '₩' + price.toLocaleString('ko-KR'); return '₩' + price.toLocaleString('ko-KR');
} }
// ============================================================
// 서버 추천 마법사 - 기술 스택 기반 추천 규칙
// ============================================================
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: '🏙️' }
}
};
/**
* 서버 추천 로직 (룰 기반)
* @param {string[]} selectedStacks - 선택된 스택 ID 목록
* @param {string} scale - 규모 키 (personal, small, medium, large)
* @returns {Object} 추천 결과 { economy, recommended, performance }
*/
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
};
}
/** /**
* Alpine.js 메인 앱 데이터 - 대화형 위자드 * Alpine.js 메인 앱 데이터 - 대화형 위자드
*/ */
@@ -240,6 +373,133 @@ function anvilApp() {
wizardStep: 0, // 0: region, 1: plan, 2: os, 3: payment, 4: confirm, 5+: deploying wizardStep: 0, // 0: region, 1: plan, 2: os, 3: payment, 4: confirm, 5+: deploying
deployStep: 0, deployStep: 0,
// ============================================================
// 서버 추천 마법사 상태
// ============================================================
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;
// 텔레그램 봇으로 연결 (선택한 사양 정보 포함)
const purposeName = WIZARD_CONFIG.purposes[this.wizardPurpose]?.name || this.wizardPurpose;
const scaleName = WIZARD_CONFIG.scales[this.wizardScale]?.name || this.wizardScale;
const stackNames = this.wizardStacks.map(id => this.getWizardStackName(id)).join(', ');
const tierLabels = { economy: 'Economy', recommended: 'Recommended', performance: 'Performance' };
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;
},
// 대시보드 상태 // 대시보드 상태
dashboardMode: false, // true면 대시보드, false면 랜딩 dashboardMode: false, // true면 대시보드, false면 랜딩
currentView: 'servers', // 'servers' | 'stats' | 'notifications' currentView: 'servers', // 'servers' | 'stats' | 'notifications'
@@ -937,19 +1197,19 @@ function pricingTable() {
// 글로벌(Linode) 서브탭 (인스턴스 타입별) // 글로벌(Linode) 서브탭 (인스턴스 타입별)
globalTypes: [ globalTypes: [
{ id: 'standard', name: 'Standard', desc: '공유 CPU, 저렴' }, { id: 'standard', name: 'Standard', tooltip: '공유 CPU · 1-32 vCPU · 1-192GB RAM\n버스트 가능한 CPU로 비용 효율적\n웹서버, 개발환경에 적합' },
{ id: 'dedicated', name: 'Dedicated', desc: '전용 CPU, 안정' }, { id: 'dedicated', name: 'Dedicated', tooltip: '전용 CPU · 4-64 vCPU · 8-512GB RAM\n100% CPU 자원 보장\nCI/CD, 게임서버, 고부하 작업에 적합' },
{ id: 'premium', name: 'Premium', desc: 'AMD EPYC, 고성능' }, { id: 'premium', name: 'Premium', tooltip: 'AMD EPYC 9004 · 1-64 vCPU · 2-512GB RAM\nDDR5 메모리 · NVMe 스토리지\n최신 세대 고성능 컴퓨팅' },
{ id: 'highmem', name: 'High Memory', desc: '대용량 RAM' }, { id: 'highmem', name: 'High Memory', tooltip: '고밀도 메모리 · 2-16 vCPU · 24-300GB RAM\nvCPU당 RAM 비율 최대화\n데이터베이스, 캐싱, 분석 워크로드' },
], ],
// 서울 서브탭 (인스턴스 타입별) // 서울 서브탭 (인스턴스 타입별)
seoulTypes: [ seoulTypes: [
{ id: 'vc2', name: '가성비', desc: '일반 SSD' }, { id: 'vc2', name: 'Cloud Compute', tooltip: '일반 SSD · 1-24 vCPU · 1-96GB RAM\n가성비 좋은 범용 인스턴스\n웹호스팅, 소규모 앱에 적합' },
{ id: 'vhf', name: '고주파', desc: 'NVMe·3GHz+' }, { id: 'vhf', name: 'High Frequency', tooltip: 'NVMe SSD · 3GHz+ CPU · 1-12 vCPU\n고클럭 프로세서로 단일 스레드 성능 극대화\n게임서버, 실시간 처리에 적합' },
{ id: 'vhp', name: '고성능', desc: 'AMD/Intel' }, { id: 'vhp', name: 'High Performance', tooltip: 'AMD EPYC · 1-16 vCPU · 1-64GB RAM\n전용 CPU 코어 · NVMe 스토리지\n고성능 컴퓨팅, ML 추론에 적합' },
{ id: 'voc', name: '최적화', desc: 'CPU/메모리/스토리지' }, { id: 'voc', name: 'Optimized', tooltip: '특화 인스턴스 · 다양한 구성\nCPU/메모리/스토리지 최적화 버전\n특정 워크로드에 맞춤 선택' },
{ id: 'vx1', name: '차세대', desc: '고밀도 메모리' }, { id: 'vx1', name: 'Extreme', tooltip: '고밀도 · 4-24 vCPU · 96-384GB RAM\n고메모리 비율 인스턴스\n대규모 DB, 인메모리 캐시에 적합' },
], ],
// 데이터 상태 // 데이터 상태
@@ -1157,8 +1417,12 @@ function pricingTable() {
} }
case 'seoul': case 'seoul':
// 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외 // 서울은 서브탭(selectedSeoulType)으로 필터링, GPU 제외
// vhp는 AMD/Intel 중복 → AMD만 표시
const isSeoul = regionName.includes('seoul'); const isSeoul = regionName.includes('seoul');
return isSeoul && instId.startsWith(this.selectedSeoulType + '-') && !hasGpu; 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': case 'gpu-japan':
// 일본 GPU (도쿄만, 중복 방지) // 일본 GPU (도쿄만, 중복 방지)
return regionName.includes('tokyo 2') && hasGpu; return regionName.includes('tokyo 2') && hasGpu;
@@ -1342,6 +1606,59 @@ function pricingTable() {
if (diff < 60) return '방금 전'; if (diff < 60) return '방금 전';
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`; if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
return this.lastUpdate.toLocaleTimeString('ko-KR'); 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)}`;
} }
}; };
} }

View File

@@ -213,9 +213,9 @@
<!-- CTA & Ping Widget --> <!-- CTA & Ping Widget -->
<div class="flex flex-col sm:flex-row gap-6 items-center justify-center lg:justify-start animate-fade-in-delay-2"> <div class="flex flex-col sm:flex-row gap-6 items-center justify-center lg:justify-start animate-fade-in-delay-2">
<button @click="openLauncher()" :aria-expanded="launcherOpen" aria-haspopup="dialog" aria-label="인스턴스 즉시 배포 - 서버 런처 열기" class="btn-gradient px-8 py-4 text-dark-900 font-bold rounded-xl flex items-center justify-center gap-3 group"> <button @click="openWizard()" :aria-expanded="wizardOpen" aria-haspopup="dialog" aria-label="지금 서버 만들기 - 서버 추천 마법사 열기" class="btn-gradient px-8 py-4 text-dark-900 font-bold rounded-xl flex items-center justify-center gap-3 group">
<span class="text-xl">🚀</span> <span class="text-xl">🚀</span>
<span>인스턴스 즉시 배포</span> <span>지금 서버 만들기</span>
<svg class="w-4 h-4 text-brand-600 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg> <svg class="w-4 h-4 text-brand-600 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</button> </button>
@@ -1253,6 +1253,220 @@
</div> </div>
<!-- Server Recommendation Wizard Modal -->
<div x-show="wizardOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="closeWizard()"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-dark-900/90 backdrop-blur-sm"
style="display: none;">
<div @click.away="closeWizard()"
role="dialog"
aria-modal="true"
aria-labelledby="wizard-title"
class="bg-slate-900/95 backdrop-blur-xl border border-white/10 rounded-3xl shadow-2xl shadow-black/50 overflow-hidden w-full max-w-lg">
<!-- Wizard Header -->
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-brand-500 to-purple-500 flex items-center justify-center text-lg">🧙</div>
<div>
<h2 id="wizard-title" class="text-lg font-bold text-white">서버 추천 마법사</h2>
<p class="text-xs text-slate-400">기술 스택에 맞는 최적의 서버를 찾아드립니다</p>
</div>
</div>
<button @click="closeWizard()"
aria-label="마법사 닫기"
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white transition">
<span>×</span>
</button>
</div>
<!-- Progress Bar -->
<div class="px-6 pt-4">
<div class="flex items-center gap-2 mb-2">
<template x-for="step in 4" :key="step">
<div class="flex-1 h-1.5 rounded-full transition-all duration-300"
:class="wizardCurrentStep >= step - 1 ? 'bg-brand-500' : 'bg-slate-700'"></div>
</template>
</div>
<div class="flex justify-between text-xs text-slate-500">
<span :class="wizardCurrentStep === 0 && 'text-brand-400 font-medium'">목적</span>
<span :class="wizardCurrentStep === 1 && 'text-brand-400 font-medium'">기술스택</span>
<span :class="wizardCurrentStep === 2 && 'text-brand-400 font-medium'">규모</span>
<span :class="wizardCurrentStep === 3 && 'text-brand-400 font-medium'">추천</span>
</div>
</div>
<!-- Wizard Content -->
<div class="p-6 max-h-[60vh] overflow-y-auto">
<!-- Step 0: Purpose Selection -->
<div x-show="wizardCurrentStep === 0" x-transition>
<h3 class="text-base font-semibold text-white mb-4">어떤 용도로 서버를 사용하시나요?</h3>
<div class="grid grid-cols-2 gap-3">
<template x-for="purpose in Object.keys(WIZARD_CONFIG.purposes)" :key="purpose">
<button @click="selectWizardPurpose(purpose)"
class="p-4 rounded-xl border-2 transition-all text-left hover:border-brand-500/50 hover:bg-brand-500/5"
:class="wizardPurpose === purpose ? 'border-brand-500 bg-brand-500/10' : 'border-slate-700/50 bg-slate-800/50'">
<div class="text-2xl mb-2" x-text="WIZARD_CONFIG.purposes[purpose].icon"></div>
<div class="font-medium text-white text-sm" x-text="WIZARD_CONFIG.purposes[purpose].name"></div>
<div class="text-xs text-slate-400 mt-1" x-text="WIZARD_CONFIG.purposes[purpose].desc"></div>
</button>
</template>
</div>
</div>
<!-- Step 1: Stack Selection -->
<div x-show="wizardCurrentStep === 1" x-transition>
<h3 class="text-base font-semibold text-white mb-2">사용할 기술 스택을 선택하세요</h3>
<p class="text-xs text-slate-400 mb-4">복수 선택 가능합니다</p>
<div class="space-y-4">
<template x-for="(stacks, category) in getWizardStacksByPurpose()" :key="category">
<div>
<h4 class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2" x-text="category"></h4>
<div class="flex flex-wrap gap-2">
<template x-for="stack in stacks" :key="stack.id">
<button @click="toggleWizardStack(stack.id)"
class="px-3 py-2 rounded-lg border transition-all text-sm flex items-center gap-2"
:class="wizardStacks.includes(stack.id)
? 'border-brand-500 bg-brand-500/20 text-brand-300'
: 'border-slate-700 bg-slate-800/50 text-slate-300 hover:border-slate-600'">
<span x-text="stack.icon"></span>
<span x-text="stack.name"></span>
</button>
</template>
</div>
</div>
</template>
</div>
<div class="mt-6 flex gap-3">
<button @click="wizardGoBack()" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition text-sm">
← 이전
</button>
<button @click="confirmWizardStacks()"
:disabled="wizardStacks.length === 0"
class="flex-1 py-3 bg-brand-500 hover:bg-brand-600 disabled:bg-slate-700 disabled:text-slate-500 text-white font-medium rounded-xl transition text-sm">
다음 →
</button>
</div>
</div>
<!-- Step 2: Scale Selection -->
<div x-show="wizardCurrentStep === 2" x-transition>
<h3 class="text-base font-semibold text-white mb-4">예상 사용 규모를 선택하세요</h3>
<div class="space-y-3">
<template x-for="(scale, key) in WIZARD_CONFIG.scales" :key="key">
<button @click="selectWizardScale(key)"
class="w-full p-4 rounded-xl border-2 transition-all text-left hover:border-brand-500/50"
:class="wizardScale === key ? 'border-brand-500 bg-brand-500/10' : 'border-slate-700/50 bg-slate-800/50'">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-xl" x-text="scale.icon"></span>
<span class="font-medium text-white" x-text="scale.name"></span>
</div>
<div class="text-xs text-slate-400 mt-1" x-text="scale.desc"></div>
</div>
<div class="text-xs text-slate-500" x-text="scale.users"></div>
</div>
</button>
</template>
</div>
<div class="mt-6">
<button @click="wizardGoBack()" class="w-full py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition text-sm">
← 이전
</button>
</div>
</div>
<!-- Step 3: Recommendations -->
<div x-show="wizardCurrentStep === 3" x-transition>
<h3 class="text-base font-semibold text-white mb-2">추천 서버 사양</h3>
<p class="text-xs text-slate-400 mb-4">선택하신 조건에 맞는 최적의 서버입니다</p>
<div class="space-y-3">
<!-- Economy -->
<div x-show="wizardRecommendations?.economy"
class="p-4 rounded-xl border border-slate-700/50 bg-slate-800/50 hover:border-slate-600 transition cursor-pointer"
@click="selectWizardPlan('economy')">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-slate-400 uppercase">Economy</span>
<span class="text-xs text-slate-500">최소 사양</span>
</div>
<div class="flex items-baseline gap-2 mb-2">
<span class="text-2xl font-bold text-white" x-text="wizardRecommendations?.economy?.cpu + ' vCPU'"></span>
<span class="text-slate-400">/</span>
<span class="text-lg text-slate-300" x-text="formatWizardRam(wizardRecommendations?.economy?.ram)"></span>
</div>
<div class="text-xs text-slate-500" x-text="'$' + wizardRecommendations?.economy?.price?.toFixed(2) + '/월 ~'"></div>
</div>
<!-- Recommended -->
<div x-show="wizardRecommendations?.recommended"
class="p-4 rounded-xl border-2 border-brand-500/50 bg-brand-500/5 hover:border-brand-500 transition cursor-pointer relative"
@click="selectWizardPlan('recommended')">
<div class="absolute -top-2.5 left-4 px-2 py-0.5 bg-brand-500 text-white text-xs font-medium rounded">추천</div>
<div class="flex items-center justify-between mb-2 pt-1">
<span class="text-xs font-medium text-brand-400 uppercase">Recommended</span>
<span class="text-xs text-slate-500">최적 사양</span>
</div>
<div class="flex items-baseline gap-2 mb-2">
<span class="text-2xl font-bold text-white" x-text="wizardRecommendations?.recommended?.cpu + ' vCPU'"></span>
<span class="text-slate-400">/</span>
<span class="text-lg text-slate-300" x-text="formatWizardRam(wizardRecommendations?.recommended?.ram)"></span>
</div>
<div class="text-xs text-slate-500" x-text="'$' + wizardRecommendations?.recommended?.price?.toFixed(2) + '/월 ~'"></div>
</div>
<!-- Performance -->
<div x-show="wizardRecommendations?.performance"
class="p-4 rounded-xl border border-purple-500/30 bg-purple-500/5 hover:border-purple-500/50 transition cursor-pointer"
@click="selectWizardPlan('performance')">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-purple-400 uppercase">Performance</span>
<span class="text-xs text-slate-500">고성능</span>
</div>
<div class="flex items-baseline gap-2 mb-2">
<span class="text-2xl font-bold text-white" x-text="wizardRecommendations?.performance?.cpu + ' vCPU'"></span>
<span class="text-slate-400">/</span>
<span class="text-lg text-slate-300" x-text="formatWizardRam(wizardRecommendations?.performance?.ram)"></span>
</div>
<div class="text-xs text-slate-500" x-text="'$' + wizardRecommendations?.performance?.price?.toFixed(2) + '/월 ~'"></div>
</div>
</div>
<!-- Summary -->
<div class="mt-4 p-3 bg-slate-800/50 rounded-xl">
<div class="text-xs text-slate-400 mb-2">선택 요약</div>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 bg-slate-700/50 rounded text-xs text-slate-300" x-text="WIZARD_CONFIG.purposes[wizardPurpose]?.name"></span>
<template x-for="stackId in wizardStacks" :key="stackId">
<span class="px-2 py-1 bg-slate-700/50 rounded text-xs text-slate-300" x-text="getWizardStackName(stackId)"></span>
</template>
<span class="px-2 py-1 bg-slate-700/50 rounded text-xs text-slate-300" x-text="WIZARD_CONFIG.scales[wizardScale]?.name"></span>
</div>
</div>
<div class="mt-6">
<button @click="wizardGoBack()" class="w-full py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition text-sm">
← 다시 선택하기
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scroll Animation Script --> <!-- Scroll Animation Script -->
<script> <script>
// Intersection Observer for scroll animations // Intersection Observer for scroll animations