feat: add terminal-style pricing table with region filtering

- Add dynamic pricing section with kubectl-style table UI
- Implement region tabs (Tokyo 1-3, Osaka 1-2, Seoul 1, Singapore 1)
- Integrate with existing /api/pricing endpoint
- Add localStorage caching with 1-hour TTL
- Apply terminal theme (Fira Code, GitHub Dark colors)
- Sort by price, filter by region

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-30 07:38:27 +09:00
parent a4e9dc2dd8
commit f3dd6c8d5a
4 changed files with 570 additions and 338 deletions

View File

@@ -1,10 +1,12 @@
/**
* Pricing endpoint - Direct D1 query (no rate limiting)
* GET /api/pricing → D1 cloud-instances-db
* Pricing endpoint - Anvil 가격표 (D1 직접 조회)
* GET /api/pricing → D1 cloud-instances-db (anvil_* 테이블)
*
* Supported regions:
* - Tokyo, Osaka, Singapore: Linode
* - Seoul: Vultr
* Anvil Regions:
* - Tokyo 1-3 (Linode × 2 + Vultr)
* - Osaka 1-2 (Linode + Vultr)
* - Seoul 1 (Vultr)
* - Singapore 1 (Linode)
*/
import { type PagesFunction } from '@cloudflare/workers-types';
@@ -13,24 +15,58 @@ interface Env {
DB: D1Database;
}
interface InstanceRow {
instance_id: string;
interface AnvilPricingRow {
instance_id: number;
instance_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number | null;
display_name: string;
category: string;
vcpus: number;
memory_gb: number;
disk_gb: number;
transfer_tb: number | null;
provider_name: string;
network_gbps: number | null;
gpu_model: string | null;
gpu_vram_gb: number | null;
region_id: number;
region_name: string;
region_code: string;
region_display_name: string;
country_code: string;
source_provider: string;
hourly_price: number;
monthly_price: number;
hourly_price: number | null;
monthly_price_krw: number | null;
hourly_price_krw: number | null;
// GPU fields
has_gpu?: number;
gpu_count?: number;
gpu_type?: string;
}
interface TransferPricingRow {
region_name: string;
region_display_name: string;
price_per_gb: number;
}
// 기본 환율 (API 실패 시 fallback)
const DEFAULT_USD_TO_KRW = 1450;
// 실시간 환율 조회 (exchangerate-api.com)
async function fetchExchangeRate(): Promise<{ rate: number; source: string }> {
try {
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
cf: { cacheTtl: 3600 }, // 1시간 캐시
});
if (!response.ok) {
throw new Error(`Exchange rate API error: ${response.status}`);
}
const data = await response.json() as { rates?: { KRW?: number } };
const rate = data.rates?.KRW;
if (rate && rate > 0) {
return { rate: Math.round(rate), source: 'realtime' };
}
throw new Error('Invalid exchange rate data');
} catch (error) {
console.error('[Pricing] Exchange rate fetch failed:', error);
return { rate: DEFAULT_USD_TO_KRW, source: 'fallback' };
}
}
const CORS_HEADERS = {
@@ -41,165 +77,151 @@ const CORS_HEADERS = {
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
try {
// 1. 일반 인스턴스 쿼리 - Linode(Tokyo, Osaka, Singapore) + Vultr(Seoul)
const regularSql = `
// 실시간 환율 조회
const exchangeRate = await fetchExchangeRate();
const USD_TO_KRW = exchangeRate.rate;
// Anvil 가격 조회 (인스턴스 + 리전 + 가격 조인)
const pricingSql = `
SELECT
it.instance_id,
it.instance_name,
it.vcpu,
it.memory_mb,
it.storage_gb,
it.transfer_tb,
p.name as provider_name,
r.region_name,
r.region_code,
pr.monthly_price,
pr.hourly_price,
pr.monthly_price_krw,
pr.hourly_price_krw,
0 as has_gpu,
0 as gpu_count,
NULL as gpu_type
FROM instance_types it
JOIN providers p ON it.provider_id = p.id
JOIN pricing pr ON it.id = pr.instance_type_id
JOIN regions r ON pr.region_id = r.id
WHERE pr.available = 1
AND it.instance_id NOT LIKE '%-sc1'
AND (
(p.name = 'linode' AND (
r.region_name LIKE '%Tokyo%' OR
r.region_name LIKE '%Osaka%' OR
r.region_code LIKE '%jp-osa%' OR
r.region_name LIKE '%Singapore%'
))
OR
(p.name = 'vultr' AND (
r.region_name LIKE '%Seoul%' OR
r.region_code LIKE '%icn%'
))
)
ORDER BY pr.monthly_price ASC
ai.id as instance_id,
ai.name as instance_name,
ai.display_name,
ai.category,
ai.vcpus,
ai.memory_gb,
ai.disk_gb,
ai.transfer_tb,
ai.network_gbps,
ai.gpu_model,
ai.gpu_vram_gb,
ar.id as region_id,
ar.name as region_name,
ar.display_name as region_display_name,
ar.country_code,
ar.source_provider,
ap.hourly_price,
ap.monthly_price
FROM anvil_pricing ap
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
WHERE ai.active = 1 AND ar.active = 1
ORDER BY ar.id, ap.monthly_price ASC
`;
// 2. GPU 인스턴스 쿼리 - Linode(Tokyo) + Vultr(Seoul)
const gpuSql = `
// 트래픽 가격 조회
const transferSql = `
SELECT
gi.instance_id,
gi.instance_name,
gi.vcpu,
gi.memory_mb,
gi.storage_gb,
gi.transfer_tb,
p.name as provider_name,
r.region_name,
r.region_code,
gp.monthly_price,
gp.hourly_price,
gp.monthly_price_krw,
gp.hourly_price_krw,
1 as has_gpu,
gi.gpu_count,
gi.gpu_type
FROM gpu_instances gi
JOIN providers p ON gi.provider_id = p.id
JOIN gpu_pricing gp ON gi.id = gp.gpu_instance_id
JOIN regions r ON gp.region_id = r.id
WHERE gp.available = 1
AND (
(p.name = 'linode' AND (
r.region_name LIKE '%Tokyo%' OR
r.region_name LIKE '%Osaka%' OR
r.region_code LIKE '%jp-osa%'
))
OR
(p.name = 'vultr' AND (
r.region_name LIKE '%Seoul%' OR
r.region_code LIKE '%icn%'
))
)
ORDER BY gp.monthly_price ASC
ar.name as region_name,
ar.display_name as region_display_name,
atp.price_per_gb
FROM anvil_transfer_pricing atp
JOIN anvil_regions ar ON atp.anvil_region_id = ar.id
WHERE ar.active = 1
`;
const [regularResult, gpuResult] = await Promise.all([
env.DB.prepare(regularSql).all<InstanceRow>(),
env.DB.prepare(gpuSql).all<InstanceRow>(),
const [pricingResult, transferResult] = await Promise.all([
env.DB.prepare(pricingSql).all<AnvilPricingRow>(),
env.DB.prepare(transferSql).all<TransferPricingRow>(),
]);
// Transform to frontend format
const transformRow = (row: InstanceRow) => ({
id: row.instance_id,
// 인스턴스 변환 (프론트엔드 형식)
const instances = pricingResult.results.map((row) => ({
id: row.instance_name,
instance_name: row.instance_name,
vcpu: row.vcpu,
memory_mb: row.memory_mb,
storage_gb: row.storage_gb,
display_name: row.display_name,
category: row.category,
vcpu: row.vcpus,
memory_mb: Math.round(row.memory_gb * 1024),
memory_gb: row.memory_gb,
storage_gb: row.disk_gb,
transfer_tb: row.transfer_tb,
provider: { name: row.provider_name },
network_gbps: row.network_gbps,
gpu_model: row.gpu_model,
gpu_vram_gb: row.gpu_vram_gb,
has_gpu: row.category === 'gpu' || row.gpu_model !== null,
region: {
region_name: row.region_name,
region_code: row.region_code
id: row.region_id,
name: row.region_name,
display_name: row.region_display_name,
country_code: row.country_code,
},
provider: row.source_provider,
pricing: {
monthly_price: row.monthly_price,
hourly_price: row.hourly_price,
monthly_price_krw: row.monthly_price_krw,
hourly_price_krw: row.hourly_price_krw,
monthly_price: row.monthly_price,
hourly_price_krw: Math.round(row.hourly_price * USD_TO_KRW), // 1원 단위 반올림
monthly_price_krw: Math.round(row.monthly_price * USD_TO_KRW / 500) * 500, // 500원 단위 반올림
},
has_gpu: row.has_gpu === 1,
gpu_count: row.gpu_count || 0,
gpu_type: row.gpu_type || null,
});
}));
const regularInstances = regularResult.results.map(transformRow);
const gpuInstances = gpuResult.results.map(transformRow);
const instances = [...regularInstances, ...gpuInstances];
// 트래픽 가격 변환 (리전별) - 1원 단위 반올림
const transferPricing = transferResult.results.reduce((acc, row) => {
acc[row.region_display_name] = {
price_per_gb: row.price_per_gb,
price_per_gb_krw: Math.round(row.price_per_gb * USD_TO_KRW),
};
return acc;
}, {} as Record<string, { price_per_gb: number; price_per_gb_krw: number }>);
// Count by region
// 리전별 카운트
const regionCounts: Record<string, number> = {};
for (const inst of instances) {
const name = inst.region.region_name.toLowerCase();
if (name.includes('tokyo')) regionCounts['tokyo'] = (regionCounts['tokyo'] || 0) + 1;
else if (name.includes('osaka')) regionCounts['osaka'] = (regionCounts['osaka'] || 0) + 1;
else if (name.includes('singapore')) regionCounts['singapore'] = (regionCounts['singapore'] || 0) + 1;
else if (name.includes('seoul')) regionCounts['seoul'] = (regionCounts['seoul'] || 0) + 1;
const regionKey = inst.region.display_name.toLowerCase().replace(/\s+/g, '-');
regionCounts[regionKey] = (regionCounts[regionKey] || 0) + 1;
}
// GPU counts
const gpuCounts = {
'gpu-japan': gpuInstances.filter(i =>
i.region.region_name.toLowerCase().includes('tokyo') ||
i.region.region_name.toLowerCase().includes('osaka')
).length,
'gpu-korea': gpuInstances.filter(i =>
i.region.region_name.toLowerCase().includes('seoul')
).length,
};
// 사용 가능한 리전 목록 (순서 유지: Tokyo 1,2,3 → Osaka 1,2 → Seoul 1 → Singapore 1)
const seenRegions = new Set<number>();
const regions = instances
.filter((i) => {
if (seenRegions.has(i.region.id)) return false;
seenRegions.add(i.region.id);
return true;
})
.map((i) => ({
id: i.region.display_name.toLowerCase().replace(/\s+/g, '-'),
name: i.region.display_name,
country_code: i.region.country_code,
count: regionCounts[i.region.display_name.toLowerCase().replace(/\s+/g, '-')] || 0,
}));
return new Response(JSON.stringify({
success: true,
total: instances.length,
region_counts: regionCounts,
gpu_counts: gpuCounts,
instances,
}), {
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300', // 5분 캐시
},
});
return new Response(
JSON.stringify({
success: true,
total: instances.length,
exchange_rate: {
usd_to_krw: USD_TO_KRW,
source: exchangeRate.source,
},
region_counts: regionCounts,
regions,
transfer_pricing: transferPricing,
instances,
}),
{
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300', // 5분 캐시
},
}
);
} catch (error) {
console.error('[Pricing] D1 query error:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Database query failed',
}), {
status: 500,
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
},
});
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Database query failed',
}),
{
status: 500,
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
},
}
);
}
};

View File

@@ -176,51 +176,132 @@
</div>
</div>
<!-- Specs Section -->
<div class="grid lg:grid-cols-2 gap-12 z-10 items-start border-t border-terminal-border/50 pt-12" id="pricing">
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-display font-bold text-white flex items-center gap-2">
<span class="text-terminal-amber">$</span> cat /etc/server-specs.json
</h2>
<ul class="text-terminal-muted space-y-3">
<li class="flex items-center gap-2">
<span class="text-primary"></span> NVMe Gen 4 스토리지
</li>
<li class="flex items-center gap-2">
<span class="text-primary"></span> 25Gbps 프라이빗 네트워크
</li>
<li class="flex items-center gap-2">
<span class="text-primary"></span> AMD EPYC / Intel Xeon
</li>
<li class="flex items-center gap-2">
<span class="text-primary"></span> 실시간 스냅샷 & 백업
</li>
<li class="flex items-center gap-2">
<span class="text-primary"></span> IPv4 + IPv6 듀얼스택
</li>
</ul>
<!-- Pricing Section -->
<div class="z-10 border-t border-terminal-border/50 pt-12" id="pricing" x-data="pricingTerminal()">
<!-- Section Header -->
<div class="flex items-center gap-2 text-sm text-terminal-muted mb-6">
<span class="text-primary">$</span> kubectl get instances --all-regions
</div>
<!-- JSON Code Block -->
<div class="w-full">
<div class="rounded-lg border border-terminal-border bg-terminal-bg overflow-hidden shadow-2xl text-sm leading-relaxed">
<div class="flex border-b border-terminal-border bg-[#161b22] px-4 py-2 text-xs text-terminal-muted justify-between">
<span>pricing.json</span>
<span class="text-terminal-amber uppercase text-[10px] font-bold tracking-widest">Global Region</span>
</div>
<div class="p-4 overflow-x-auto">
<div class="table w-full">
<div class="table-row"><div class="table-cell line-num">1</div><div class="table-cell"><span class="text-terminal-amber">{</span></div></div>
<div class="table-row"><div class="table-cell line-num">2</div><div class="table-cell pl-4"><span class="syntax-key">"plan"</span><span class="syntax-colon">:</span> <span class="syntax-string">"Starter"</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">3</div><div class="table-cell pl-4"><span class="syntax-key">"cpu"</span><span class="syntax-colon">:</span> <span class="syntax-number">1</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">4</div><div class="table-cell pl-4"><span class="syntax-key">"memory"</span><span class="syntax-colon">:</span> <span class="syntax-string">"1GB"</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">5</div><div class="table-cell pl-4"><span class="syntax-key">"storage"</span><span class="syntax-colon">:</span> <span class="syntax-string">"25GB NVMe"</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">6</div><div class="table-cell pl-4"><span class="syntax-key">"bandwidth"</span><span class="syntax-colon">:</span> <span class="syntax-string">"1TB"</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">7</div><div class="table-cell pl-4"><span class="syntax-key">"price_monthly"</span><span class="syntax-colon">:</span> <span class="syntax-string">"$5"</span><span class="syntax-colon">,</span></div></div>
<div class="table-row"><div class="table-cell line-num">8</div><div class="table-cell pl-4"><span class="syntax-key">"regions"</span><span class="syntax-colon">:</span> <span class="text-terminal-amber">[</span><span class="syntax-string">"tokyo"</span>, <span class="syntax-string">"seoul"</span>, <span class="syntax-string">"singapore"</span><span class="text-terminal-amber">]</span></div></div>
<div class="table-row"><div class="table-cell line-num">9</div><div class="table-cell"><span class="text-terminal-amber">}</span></div></div>
</div>
</div>
<!-- Region Tabs -->
<div class="flex flex-wrap gap-2 mb-6">
<template x-for="region in regions" :key="region.id">
<button
@click="selectedRegion = region.id; filterInstances()"
:class="selectedRegion === region.id ? 'bg-primary text-background-dark' : 'bg-terminal-bg text-terminal-text hover:bg-terminal-border'"
class="px-3 py-1.5 rounded text-xs font-bold transition-colors border border-terminal-border flex items-center gap-2"
>
<span x-text="region.flag"></span>
<span x-text="region.name"></span>
<span class="opacity-60" x-text="'(' + getRegionCount(region.id) + ')'"></span>
</button>
</template>
</div>
<!-- Command Line -->
<div class="bg-terminal-bg border border-terminal-border rounded-t-lg px-4 py-2 text-xs text-terminal-muted flex items-center justify-between">
<span>
<span class="text-primary">$</span> kubectl get instances --region=<span class="text-terminal-cyan" x-text="selectedRegion"></span> --sort-by=<span class="text-terminal-amber" x-text="sortBy"></span>
</span>
<span x-show="loading" class="text-terminal-amber animate-pulse">fetching...</span>
<span x-show="!loading && lastUpdate" class="text-terminal-muted" x-text="'synced ' + getLastUpdateText()"></span>
</div>
<!-- Instances Table -->
<div class="bg-terminal-bg border-x border-b border-terminal-border rounded-b-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-terminal-border bg-[#161b22] text-left">
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white" @click="toggleSort('name')">
NAME <span x-show="sortBy === 'name'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('vcpu')">
CPU <span x-show="sortBy === 'vcpu'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('memory')">
RAM <span x-show="sortBy === 'memory'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-4 py-3 font-bold text-terminal-muted text-center hidden sm:table-cell">DISK</th>
<th class="px-4 py-3 font-bold text-terminal-muted text-center hidden md:table-cell">TRAFFIC</th>
<th class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-right" @click="toggleSort('price')">
PRICE/MO <span x-show="sortBy === 'price'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
</tr>
</thead>
<tbody>
<!-- Loading State -->
<template x-if="loading">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
<span class="animate-pulse">Fetching instances from API...</span>
</td>
</tr>
</template>
<!-- Empty State -->
<template x-if="!loading && filteredInstances.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
No instances found for this region.
</td>
</tr>
</template>
<!-- Instance Rows -->
<template x-for="(inst, index) in filteredInstances" :key="inst.id + '-' + inst.region?.display_name">
<tr class="border-b border-terminal-border/50 hover:bg-terminal-border/20 transition-colors">
<td class="px-4 py-3">
<span class="text-terminal-cyan" x-text="inst.id || inst.instance_name"></span>
</td>
<td class="px-4 py-3 text-center">
<span class="text-terminal-amber" x-text="inst.vcpu"></span>
</td>
<td class="px-4 py-3 text-center">
<span class="text-primary" x-text="formatMemory(inst.memory_mb)"></span>
</td>
<td class="px-4 py-3 text-center text-terminal-muted hidden sm:table-cell" x-text="inst.storage_gb + 'GB'"></td>
<td class="px-4 py-3 text-center text-terminal-muted hidden md:table-cell" x-text="inst.transfer_tb ? inst.transfer_tb + 'TB' : '-'"></td>
<td class="px-4 py-3 text-right">
<span class="text-white font-bold" x-text="'$' + inst.pricing?.monthly_price?.toFixed(0)"></span>
<span class="text-terminal-muted text-xs block" x-text="'₩' + (inst.pricing?.monthly_price_krw || 0).toLocaleString()"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Table Footer -->
<div class="px-4 py-3 border-t border-terminal-border bg-[#161b22] text-xs text-terminal-muted flex justify-between items-center">
<span>
<span class="text-primary" x-text="filteredInstances.length"></span> instances
<span x-show="fromCache" class="text-terminal-amber ml-2">(cached)</span>
</span>
<button @click="forceRefresh()" class="hover:text-primary transition-colors flex items-center gap-1" :disabled="loading">
<span class="material-symbols-outlined text-sm" :class="loading && 'animate-spin'">refresh</span>
refresh
</button>
</div>
</div>
<!-- Server Specs -->
<div class="mt-8 grid md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># Storage</div>
<div class="text-white font-bold">NVMe Gen 4</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># Network</div>
<div class="text-white font-bold">25Gbps Private</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># CPU</div>
<div class="text-white font-bold">AMD EPYC / Xeon</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># IP</div>
<div class="text-white font-bold">IPv4 + IPv6</div>
</div>
</div>
</div>
@@ -269,5 +350,186 @@
</footer>
</div>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
<script>
function pricingTerminal() {
return {
// State
instances: [],
filteredInstances: [],
selectedRegion: 'tokyo-1',
sortBy: 'price',
sortOrder: 'asc',
loading: false,
lastUpdate: null,
fromCache: false,
// Region definitions
regions: [
{ id: 'tokyo-1', name: 'Tokyo 1', flag: '🇯🇵', filter: ['tokyo 1'] },
{ id: 'tokyo-2', name: 'Tokyo 2', flag: '🇯🇵', filter: ['tokyo 2'] },
{ id: 'tokyo-3', name: 'Tokyo 3', flag: '🇯🇵', filter: ['tokyo 3'] },
{ id: 'osaka-1', name: 'Osaka 1', flag: '🇯🇵', filter: ['osaka 1'] },
{ id: 'osaka-2', name: 'Osaka 2', flag: '🇯🇵', filter: ['osaka 2'] },
{ id: 'seoul-1', name: 'Seoul 1', flag: '🇰🇷', filter: ['seoul'] },
{ id: 'singapore-1', name: 'Singapore 1', flag: '🇸🇬', filter: ['singapore'] },
],
// Initialize
async init() {
await this.loadInstances();
},
// Load from API
async loadInstances() {
// Check cache first
const cached = this.getCache();
if (cached) {
this.instances = cached.instances;
this.lastUpdate = new Date(cached.timestamp);
this.fromCache = true;
this.filterInstances();
return;
}
await this.fetchFromApi();
},
// Fetch from API
async fetchFromApi() {
this.loading = true;
this.fromCache = false;
try {
const res = await fetch('/api/pricing');
const data = await res.json();
if (data.success && data.instances) {
this.instances = data.instances;
this.setCache(data.instances);
this.lastUpdate = new Date();
}
} catch (e) {
console.error('[Pricing] Fetch error:', e);
// Use fallback data
this.instances = this.getFallbackData();
} finally {
this.loading = false;
this.filterInstances();
}
},
// Filter instances by region
filterInstances() {
const region = this.regions.find(r => r.id === this.selectedRegion);
if (!region) return;
this.filteredInstances = this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
// GPU 제외, 리전 필터 매칭
const matchesFilter = region.filter.some(f => displayName.includes(f));
return matchesFilter && !hasGpu;
});
// Sort
this.sortInstances();
},
// Sort instances
sortInstances() {
const order = this.sortOrder === 'asc' ? 1 : -1;
this.filteredInstances.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);
break;
case 'memory':
diff = (a.memory_mb || 0) - (b.memory_mb || 0);
break;
case 'name':
diff = (a.id || '').localeCompare(b.id || '');
break;
}
return diff * order;
});
},
// Toggle sort
toggleSort(column) {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = column;
this.sortOrder = column === 'price' ? 'asc' : 'desc';
}
this.sortInstances();
},
// Get region count
getRegionCount(regionId) {
const region = this.regions.find(r => r.id === regionId);
if (!region) return 0;
return this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
const matchesFilter = region.filter.some(f => displayName.includes(f));
return matchesFilter && !hasGpu;
}).length;
},
// Format memory
formatMemory(mb) {
if (!mb) return '-';
return mb >= 1024 ? (mb / 1024) + 'GB' : mb + 'MB';
},
// Last update text
getLastUpdateText() {
if (!this.lastUpdate) return '';
const diff = Math.floor((Date.now() - this.lastUpdate) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return this.lastUpdate.toLocaleTimeString();
},
// Force refresh
async forceRefresh() {
localStorage.removeItem('anvil_pricing_cache_v6');
await this.fetchFromApi();
},
// Cache helpers
getCache() {
try {
const data = localStorage.getItem('anvil_pricing_cache_v6');
if (!data) return null;
const parsed = JSON.parse(data);
if (Date.now() - parsed.timestamp > 3600000) return null; // 1h TTL
return parsed;
} catch { return null; }
},
setCache(instances) {
try {
localStorage.setItem('anvil_pricing_cache_v6', JSON.stringify({
instances, timestamp: Date.now()
}));
} catch {}
},
// Fallback data
getFallbackData() {
return [
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
{ id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } },
];
}
};
}
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
/**
* Pricing Table Component
* API 연동 가격표 (아시아 전용, 캐싱 적용)
* Anvil 가격표 (D1 직접 조회)
*/
/**
@@ -8,32 +8,27 @@
*/
export function pricingTable() {
// 캐시 키 및 유효 시간 (1시간)
const CACHE_KEY = 'anvil_pricing_cache_v3'; // v3: Global 탭 통합 (도쿄/오사카/싱가폴)
const CACHE_KEY = 'anvil_pricing_cache_v6'; // v6: D1 API 형식 호환
const CACHE_TTL = 60 * 60 * 1000; // 1시간
// Fallback 데이터 (API 실패 시 사용) - Linode: Tokyo R1/R2/Osaka/Singapore, Vultr: Seoul
// Fallback 데이터 (API 실패 시 사용)
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 } },
{ 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: 'vcpu', // 'vcpu' | 'memory' | 'price'
sortBy: 'price', // 'vcpu' | 'memory' | 'price'
sortOrder: 'asc', // 'asc' | 'desc'
selectedCity: 'global', // 'global' | 'seoul' | 'gpu-japan' | 'gpu-korea'
selectedSeoulType: 'vc2', // 'vc2' | 'vhf' (서울 서브탭)
selectedGlobalType: 'standard', // 'standard' | 'dedicated' | 'premium' (글로벌 서브탭)
selectedSeoulType: 'all', // 'all' (서브탭 단순화)
selectedGlobalType: 'all', // 'all' (서브탭 단순화)
// 도시 목록: Linode(도쿄/오사카/싱가폴 동일가격), Seoul(Vultr), GPU
// 도시 목록
cities: [
{ id: 'global', name: '도쿄/오사카/싱가폴', flag: '🌏', provider: 'linode' },
{ id: 'seoul', name: 'Seoul', flag: '🇰🇷', provider: 'vultr' },
@@ -41,21 +36,14 @@ export function pricingTable() {
{ 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데이터베이스, 캐싱, 분석 워크로드' },
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\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, 인메모리 캐시에 적합' },
{ id: 'all', name: 'All', tooltip: '모든 인스턴스 타입\n서울 리전' },
],
// 데이터 상태
@@ -66,9 +54,6 @@ export function pricingTable() {
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();
@@ -127,14 +112,13 @@ export function pricingTable() {
}
},
// API에서 인스턴스 가져오기 (D1 직접 조회 - rate limit 없음)
// API에서 인스턴스 가져오기
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();
@@ -147,14 +131,11 @@ export function pricingTable() {
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)');
console.log('[PricingTable] Loaded', this.instances.length, 'instances');
} else {
throw new Error('인스턴스 데이터가 없습니다.');
}
@@ -174,7 +155,7 @@ export function pricingTable() {
console.log('[PricingTable] Using expired cache as fallback');
return;
} catch (e) {
// 캐시 파싱 실패, fallback 사용
// 캐시 파싱 실패
}
}
@@ -189,11 +170,10 @@ export function pricingTable() {
}
},
// 필터 및 정렬 적용 (모든 인스턴스 표시)
// 필터 및 정렬 적용
filterAndSort(instances) {
let filtered = [...instances];
// 정렬 적용
filtered.sort((a, b) => {
switch (this.sortBy) {
case 'price':
@@ -210,30 +190,27 @@ export function pricingTable() {
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();
// 리전 이름에서 도시 추출
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;
// vcpu, memory는 내림차순이 기본, price는 오름차순이 기본
this.sortOrder = column === 'price' ? 'asc' : 'desc';
}
this.onFilterChange();
@@ -243,41 +220,33 @@ export function pricingTable() {
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;
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
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;
}
// Global: 도쿄/오사카/싱가폴 (GPU 제외)
const isGlobal = displayName.includes('tokyo') ||
displayName.includes('osaka') ||
displayName.includes('singapore');
return isGlobal && !hasGpu;
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;
// 서울 (GPU 제외)
return displayName.includes('seoul') && !hasGpu;
case 'gpu-japan':
// 일본 GPU (도쿄만, 중복 방지)
return regionName.includes('tokyo 2') && hasGpu;
// 일본 GPU
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
case 'gpu-korea':
// 한국 GPU
return regionName.includes('seoul') && hasGpu;
default: return false;
return displayName.includes('seoul') && hasGpu;
default:
return false;
}
});
// 정렬 (sortOrder 반영, 보조 정렬: 가격)
// 정렬
const order = this.sortOrder === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
let diff = 0;
@@ -287,15 +256,11 @@ export function pricingTable() {
break;
case 'vcpu':
diff = (a.vcpu || 0) - (b.vcpu || 0);
if (diff === 0) {
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 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);
}
if (diff === 0) diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
break;
default:
diff = (a.vcpu || 0) - (b.vcpu || 0);
@@ -309,49 +274,55 @@ export function pricingTable() {
// 도시별 인스턴스 수 조회
getInstanceCountByCity(cityId) {
return this.instances.filter(inst => {
const regionName = (inst.region?.region_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.gpu_count > 0;
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
switch (cityId) {
case 'global':
return regionName.includes('tokyo 2') && !hasGpu;
const isGlobal = displayName.includes('tokyo') ||
displayName.includes('osaka') ||
displayName.includes('singapore');
return isGlobal && !hasGpu;
case 'seoul':
return regionName.includes('seoul') && !hasGpu;
return displayName.includes('seoul') && !hasGpu;
case 'gpu-japan':
return regionName.includes('tokyo 2') && hasGpu;
return (displayName.includes('tokyo') || displayName.includes('osaka')) && hasGpu;
case 'gpu-korea':
return regionName.includes('seoul') && hasGpu;
default: return false;
return displayName.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;
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) {
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;
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;
},
// 강제 새로고침 (캐시 삭제 후 API 호출)
// 강제 새로고침
async forceRefresh() {
localStorage.removeItem(CACHE_KEY);
await this.fetchFromApi();
@@ -363,13 +334,13 @@ export function pricingTable() {
return '$' + price.toFixed(2);
},
// 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
// 가격 포맷 (KRW)
formatKrw(krwPrice) {
if (krwPrice == null) return '-';
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
},
// 시간당 가격 포맷 (KRW) - DB에서 직접 가져온 한화 금액
// 시간당 가격 포맷 (KRW)
formatKrwHourly(krwPrice) {
if (krwPrice == null) return '-';
return '₩' + Math.round(krwPrice).toLocaleString('ko-KR');
@@ -377,6 +348,7 @@ export function pricingTable() {
// 메모리 포맷
formatMemory(mb) {
if (mb == null) return '-';
if (mb >= 1024) {
return (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1) + ' GB';
}
@@ -401,32 +373,12 @@ export function pricingTable() {
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();
@@ -448,19 +400,14 @@ export function pricingTable() {
// 인스턴스 선택 상태
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?.region?.display_name === inst.region?.display_name) {
this.selectedInstance = null;
this.showInstanceModal = false;
this.selectedInstanceDetail = null;
@@ -493,10 +440,11 @@ export function pricingTable() {
});
},
// 텔레그램 링크 가져오기
// 텔레그램 링크
getInstanceTelegramLink() {
if (!this.selectedInstanceDetail) return '#';
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}`;
const region = this.selectedInstanceDetail.region?.display_name || '';
return `https://t.me/AnvilForgeBot?start=order_${encodeURIComponent(this.selectedInstanceDetail.id)}_${encodeURIComponent(region.toLowerCase().replace(/\s+/g, '-'))}`;
}
};
}

File diff suppressed because one or more lines are too long