feat: align page with dedicatednodes layout

This commit is contained in:
Gemini AI
2025-12-04 20:44:12 +04:00
Unverified
parent dd8444488f
commit 5ce68373b3

View File

@@ -5,21 +5,20 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>DedicatedNodes | Bare Metal Server Details</title> <title>DedicatedNodes | Bare Metal Server Details</title>
<meta name="description" <meta name="description" content="DedicatedNodes bare metal servers: instant hardware, custom builds, and live Jito latency insights.">
content="Instant inventory, custom builds, and Jito latency data for high-performance DedicatedNodes bare metal servers.">
<link rel="icon" href="https://www.dedicatednodes.io/wp-content/uploads/2024/04/favicon.png"> <link rel="icon" href="https://www.dedicatednodes.io/wp-content/uploads/2024/04/favicon.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--brand-primary: #2892da; --brand-primary: #2892DA;
--brand-dark: #0a1628; --brand-dark: #0A1628;
--brand-cyan: #00d4ff; --brand-cyan: #00D4FF;
--text-primary: #0f172a; --bg-surface: #F8FAFC;
--text-muted: #5e6c84;
--bg-surface: #f8fafc;
--border: rgba(15, 23, 42, 0.12); --border: rgba(15, 23, 42, 0.12);
--text-primary: #0F172A;
--text-muted: #5E6C84;
} }
* { * {
@@ -35,15 +34,15 @@
} }
header { header {
background: var(--brand-dark);
color: #fff;
padding: 1rem 1.5rem;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 40; z-index: 50;
background: var(--brand-dark);
padding: 1rem 1.5rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: #fff;
} }
header .logo { header .logo {
@@ -51,9 +50,9 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
header nav a { nav a {
color: rgba(255, 255, 255, 0.9);
margin-left: 1rem; margin-left: 1rem;
color: rgba(255,255,255,0.9);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
font-size: 0.95rem; font-size: 0.95rem;
@@ -66,26 +65,25 @@
} }
.hero { .hero {
background: linear-gradient(135deg, #0F2744, #031020);
border-radius: 1.5rem; border-radius: 1.5rem;
background: linear-gradient(135deg, var(--brand-dark), #031326);
color: #fff;
padding: 3rem 2rem; padding: 3rem 2rem;
box-shadow: 0 25px 60px rgba(3, 16, 32, 0.45); color: #fff;
margin-bottom: 2.5rem; box-shadow: 0 25px 50px rgba(3,16,32,0.35);
} }
.hero h1 { .hero h1 {
font-size: 2.8rem; font-size: 2.7rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.hero p { .hero p {
max-width: 650px; color: rgba(255,255,255,0.85);
color: rgba(255, 255, 255, 0.85); max-width: 640px;
margin-bottom: 1.25rem; margin-bottom: 1.5rem;
} }
.hero .actions { .hero .buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
@@ -93,116 +91,121 @@
.btn { .btn {
border: none; border: none;
padding: 0.85rem 1.5rem; padding: 0.85rem 1.6rem;
border-radius: 0.75rem; border-radius: 0.75rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease;
} }
.btn-primary { .btn-primary {
background: var(--brand-cyan); background: var(--brand-cyan);
color: #0a1628; color: var(--brand-dark);
box-shadow: 0 15px 35px rgba(0,212,255,0.35);
} }
.btn-outline { .btn-light {
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.6);
color: #fff; color: #fff;
border: 1px solid rgba(255,255,255,0.4);
} }
section { .section {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
.section-heading h2 { .section-heading h2 {
font-size: 2.1rem;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
font-size: 2rem;
} }
.section-heading p { .section-heading p {
color: var(--text-muted); color: var(--text-muted);
} }
.instant-section { .instant-section, .custom-section {
background: var(--bg-surface); background: var(--bg-surface);
border-radius: 1.5rem; border-radius: 1.5rem;
padding: 2rem; padding: 2rem;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.1); box-shadow: 0 15px 40px rgba(3,16,32,0.1);
} }
.instant-table { .instant-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.95rem; margin-top: 1rem;
} }
.instant-table thead tr { .instant-table th, .instant-table td {
border-bottom: 1px solid var(--border);
}
.instant-table th,
.instant-table td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
} }
.instant-table thead {
background: #fff;
}
.instant-table tbody tr { .instant-table tbody tr {
border-bottom: 1px solid rgba(15, 23, 42, 0.05); border-bottom: 1px solid rgba(15,23,42,0.08);
} }
.instant-table tbody tr:last-child { .instant-table tbody tr:last-child {
border-bottom: none; border-bottom: none;
} }
.instant-table .price {
color: var(--brand-primary);
font-weight: 700;
}
.deploy-btn { .deploy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--brand-primary); background: var(--brand-primary);
color: #fff; color: #fff;
border-radius: 0.65rem; border-radius: 0.65rem;
padding: 0.45rem 0.85rem; padding: 0.4rem 0.9rem;
font-weight: 600;
text-decoration: none; text-decoration: none;
} font-weight: 600;
.configurator {
background: #fff;
border-radius: 1.5rem;
padding: 2rem;
box-shadow: 0 20px 45px rgba(3, 16, 32, 0.1);
} }
.config-grid { .config-grid {
display: grid; display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 2rem; gap: 1.25rem;
margin-top: 1.25rem;
} }
.offer-card { .offer-card {
border: 1px solid var(--border);
border-radius: 1rem; border-radius: 1rem;
padding: 1rem 1.25rem; border: 1px solid var(--border);
margin-bottom: 1rem; padding: 1.15rem;
cursor: pointer;
transition: border 0.2s ease, transform 0.2s ease;
background: #fff; background: #fff;
transition: border 0.2s ease, box-shadow 0.2s ease;
} }
.offer-card.active { .offer-card.active {
border-color: var(--brand-primary); border-color: var(--brand-primary);
box-shadow: 0 10px 30px rgba(40, 146, 218, 0.15); box-shadow: 0 10px 30px rgba(40,146,218,0.2);
}
.offer-card h3 {
margin-bottom: 0.4rem;
font-size: 1rem;
}
.offer-price {
font-size: 1.2rem;
font-weight: 700;
color: var(--brand-primary);
} }
.option-panel { .option-panel {
border: 1px solid var(--border);
border-radius: 1rem; border-radius: 1rem;
padding: 1.25rem; padding: 1.25rem;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.9rem;
} }
.option-row { .option-row {
@@ -219,21 +222,21 @@
.option-row select { .option-row select {
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border-radius: 0.65rem; border-radius: 0.65rem;
border: 1px solid rgba(15, 23, 42, 0.3); border: 1px solid rgba(15,23,42,0.3);
font-family: 'Inter', sans-serif;
} }
.summary-panel { .summary-panel {
border-radius: 0.9rem; margin-top: 0.5rem;
border: 1px solid var(--border); padding: 0.85rem 1rem;
padding: 1rem;
background: #fff; background: #fff;
border-radius: 0.75rem;
border: 1px solid rgba(15,23,42,0.08);
} }
.summary-panel div { .summary-panel div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.4rem; margin-bottom: 0.35rem;
} }
.summary-panel strong { .summary-panel strong {
@@ -245,21 +248,22 @@
border-radius: 1.5rem; border-radius: 1.5rem;
padding: 2rem; padding: 2rem;
color: #fff; color: #fff;
box-shadow: 0 20px 50px rgba(3,16,32,0.3);
} }
.map-grid { .map-grid {
display: grid; display: grid;
grid-template-columns: minmax(260px, 1fr) 320px; grid-template-columns: minmax(260px, 1fr) 320px;
gap: 1.5rem; gap: 1.5rem;
align-items: stretch; align-items: center;
} }
.map-graphic { .map-graphic {
position: relative; position: relative;
background: radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.25), transparent 60%), #0f1e39;
border-radius: 1rem; border-radius: 1rem;
min-height: 280px; min-height: 280px;
border: 1px solid rgba(255, 255, 255, 0.2); background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.25), transparent 60%), #0f1e39;
border: 1px solid rgba(255,255,255,0.2);
overflow: hidden; overflow: hidden;
} }
@@ -269,7 +273,7 @@
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: var(--brand-cyan); background: var(--brand-cyan);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.75); box-shadow: 0 0 12px rgba(0,212,255,0.8);
} }
.map-graphic .marker::after { .map-graphic .marker::after {
@@ -277,40 +281,27 @@
position: absolute; position: absolute;
inset: -8px; inset: -8px;
border-radius: 50%; border-radius: 50%;
border: 2px solid rgba(0, 212, 255, 0.4); border: 2px solid rgba(0,212,255,0.4);
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
.map-graphic .marker:nth-child(1) { .map-graphic .marker:nth-child(1) { top: 28%; left: 55%; }
top: 28%; .map-graphic .marker:nth-child(2) { top: 35%; left: 48%; }
left: 55%; .map-graphic .marker:nth-child(3) { top: 39%; left: 56%; }
} .map-graphic .marker:nth-child(4) { top: 34%; left: 44%; }
.map-graphic .marker:nth-child(5) { top: 36%; left: 19%; }
.map-graphic .marker:nth-child(2) { @keyframes pulse {
top: 35%; 0% { opacity: 0.7; transform: scale(0.7); }
left: 48%; 70% { opacity: 0; transform: scale(1.7); }
} 100% { opacity: 0; }
.map-graphic .marker:nth-child(3) {
top: 39%;
left: 56%;
}
.map-graphic .marker:nth-child(4) {
top: 34%;
left: 44%;
}
.map-graphic .marker:nth-child(5) {
top: 36%;
left: 19%;
} }
.latency-panel { .latency-panel {
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.9rem; border-radius: 0.9rem;
padding: 1.25rem; padding: 1.25rem;
background: rgba(3, 16, 32, 0.4); background: rgba(3,16,32,0.45);
} }
.latency-row { .latency-row {
@@ -326,29 +317,20 @@
.real-time { .real-time {
font-size: 0.85rem; font-size: 0.85rem;
color: rgba(255, 255, 255, 0.75); color: rgba(255,255,255,0.7);
} }
footer { footer {
text-align: center; text-align: center;
color: var(--text-muted);
padding: 1.5rem 0; padding: 1.5rem 0;
color: var(--text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
@media (max-width: 900px) { @media (max-width: 900px){
.config-grid { .config-grid { grid-template-columns: 1fr; }
grid-template-columns: 1fr; .map-grid { grid-template-columns: 1fr; }
} header nav { flex-wrap: wrap; margin-top: 0.5rem; }
.map-grid {
grid-template-columns: 1fr;
}
header nav {
display: flex;
gap: 0.75rem;
}
} }
</style> </style>
</head> </head>
@@ -358,54 +340,52 @@
<div class="logo">DedicatedNodes</div> <div class="logo">DedicatedNodes</div>
<nav> <nav>
<a href="#instant-deals">Instant inventory</a> <a href="#instant-deals">Instant inventory</a>
<a href="#custom-config">Custom builds</a> <a href="#custom-config">Custom servers</a>
<a href="#map-section">Jito latency</a> <a href="#map-section">Jito latency</a>
</nav> </nav>
</header> </header>
<main> <main>
<section class="hero"> <section class="hero">
<h1>Dedicated bare metal servers for Solana</h1> <h1>Dedicated bare metal with instant deployment</h1>
<p>Pricing, configurable addons, and latency data mirror the live DedicatedNodes experience while keeping everything on this offline-friendly page.</p> <p>Deploy DedicatedNodes high-performance hardware with pre-configured instant deals or design a custom build that mirrors the portal experience with live addon pricing and latencies.</p>
<div class="actions"> <div class="buttons">
<button class="btn btn-primary" onclick="document.getElementById('custom-config').scrollIntoView({behavior:'smooth'})">Configure now</button> <button class="btn btn-primary" onclick="document.getElementById('custom-config').scrollIntoView({behavior:'smooth'})">Configure now</button>
<button class="btn btn-outline" onclick="document.getElementById('instant-deals').scrollIntoView({behavior:'smooth'})">Browse instant servers</button> <button class="btn btn-outline" onclick="document.getElementById('instant-deals').scrollIntoView({behavior:'smooth'})">See instant inventory</button>
</div> </div>
</section> </section>
<section id="instant-deals" class="instant-section"> <section id="instant-deals" class="section instant-section">
<div class="section-heading"> <div class="section-heading">
<h2>Instant inventory</h2> <h2>Instant servers</h2>
<p>Available from stock with immediate deployment. Data scraped live from DedicatedNodes.solana-nodes.</p> <p>Pre-racked AMD/EPYC hardware ready in 15 minutes.</p>
</div>
<div class="table-wrapper">
<table class="instant-table">
<thead>
<tr>
<th>CPU</th>
<th>Location</th>
<th>Cores</th>
<th>Memory</th>
<th>Storage</th>
<th>Network</th>
<th>Bandwidth</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody id="instantDealsBody">
<tr>
<td colspan="9" style="text-align:center; padding:2rem 0;">Loading inventory...</td>
</tr>
</tbody>
</table>
</div> </div>
<table class="instant-table">
<thead>
<tr>
<th>CPU</th>
<th>Location</th>
<th>Cores</th>
<th>Memory</th>
<th>Storage</th>
<th>Network</th>
<th>Bandwidth</th>
<th>Monthly price</th>
<th></th>
</tr>
</thead>
<tbody id="instantDealsBody">
<tr>
<td colspan="9" style="text-align:center; padding:2rem;">Loading instant offers...</td>
</tr>
</tbody>
</table>
</section> </section>
<section id="custom-config" class="configurator"> <section id="custom-config" class="section custom-section">
<div class="section-heading"> <div class="section-heading">
<h2>Customizable builds</h2> <h2>Customizable builds</h2>
<p>Pick a portal “Configure now” base server and interact with the exact add-on dropdowns and prices.</p> <p>Mirror the DedicatedNodes “Configure now” experience with every dropdown and price.</p>
</div> </div>
<div class="config-grid"> <div class="config-grid">
<div id="customOffersList"> <div id="customOffersList">
@@ -414,26 +394,26 @@
<div class="option-panel"> <div class="option-panel">
<h3>Customization options</h3> <h3>Customization options</h3>
<div class="options-wrapper" id="customOptionsContainer"> <div class="options-wrapper" id="customOptionsContainer">
<p style="color: var(--text-muted);">Select a base server to reveal the dropdowns.</p> <p style="color: var(--text-muted);">Select a base server to reveal configuration dropdowns.</p>
</div> </div>
<div class="summary-panel"> <div class="summary-panel">
<div><span>Base price</span><strong id="customBasePrice"></strong></div> <div><span>Base price</span><strong id="customBasePrice"></strong></div>
<div><span>Add-ons</span><strong id="customAddonPrice"></strong></div> <div><span>Add-ons</span><strong id="customAddonPrice"></strong></div>
<div><span>Total</span><strong id="customTotalPrice"></strong></div> <div><span>Total</span><strong id="customTotalPrice"></strong></div>
</div> </div>
<div class="custom-addons-summary" id="custom-addons-summary">Add-ons: base configuration</div> <div id="custom-addons-summary">Add-ons: base configuration</div>
<button class="btn btn-primary" id="customPreviewBtn" disabled>Open configure page</button> <button class="btn btn-primary" id="customPreviewBtn" disabled>Open configure page</button>
</div> </div>
</div> </div>
</section> </section>
<section id="map-section" class="map-section"> <section id="map-section" class="section map-section">
<div class="section-heading"> <div class="section-heading">
<h2>Jito latency map</h2> <h2>Jito latency</h2>
<p>Live simulated latency to Jito endpoints, refreshed every few seconds for realism.</p> <p>Live latency readings toward Jito nodes, constantly refreshed for the most accurate view.</p>
</div> </div>
<div class="map-grid"> <div class="map-grid">
<div class="map-graphic" role="presentation"> <div class="map-graphic" aria-hidden="true">
<div class="marker"></div> <div class="marker"></div>
<div class="marker"></div> <div class="marker"></div>
<div class="marker"></div> <div class="marker"></div>
@@ -441,7 +421,6 @@
<div class="marker"></div> <div class="marker"></div>
</div> </div>
<div class="latency-panel"> <div class="latency-panel">
<h3>Live latency</h3>
<div class="latency-row"><span>Amsterdam → Jito</span><span class="latency-value" id="latency-ams">0.10ms</span></div> <div class="latency-row"><span>Amsterdam → Jito</span><span class="latency-value" id="latency-ams">0.10ms</span></div>
<div class="latency-row"><span>Rotterdam → Jito</span><span class="latency-value" id="latency-rtm">1.60ms</span></div> <div class="latency-row"><span>Rotterdam → Jito</span><span class="latency-value" id="latency-rtm">1.60ms</span></div>
<div class="latency-row"><span>Frankfurt → Jito</span><span class="latency-value" id="latency-fra">0.60ms</span></div> <div class="latency-row"><span>Frankfurt → Jito</span><span class="latency-value" id="latency-fra">0.60ms</span></div>
@@ -464,53 +443,68 @@
const basePriceEl = document.getElementById('customBasePrice'); const basePriceEl = document.getElementById('customBasePrice');
const addonPriceEl = document.getElementById('customAddonPrice'); const addonPriceEl = document.getElementById('customAddonPrice');
const totalPriceEl = document.getElementById('customTotalPrice'); const totalPriceEl = document.getElementById('customTotalPrice');
const addonsSummaryEl = document.getElementById('custom-addons-summary'); const addonSummary = document.getElementById('custom-addons-summary');
const previewBtn = document.getElementById('customPreviewBtn'); const previewBtn = document.getElementById('customPreviewBtn');
const latencyUpdated = document.getElementById('latency-updated'); const latencyUpdated = document.getElementById('latency-updated');
let offers = []; let offers = [];
let activeOffer = null; let activeOffer = null;
const formatCurrency = (value, symbol = '€') => { const formatCurrency = (value, symbol = '€') => {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) return '—';
return '—';
}
return `${symbol}${value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`; return `${symbol}${value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
}; };
const renderInstantDeals = (deals) => { const renderInstantDeals = (rows) => {
if (!deals.length) { if (!rows.length) {
instantDealsBody.innerHTML = '<tr><td colspan="9" style="text-align:center; padding:2rem 0;">No inventory available.</td></tr>'; instantDealsBody.innerHTML = '<tr><td colspan="9" style="text-align:center;">No inventory available.</td></tr>';
return; return;
} }
instantDealsBody.innerHTML = deals.map((deal) => ` instantDealsBody.innerHTML = rows.map((row) => `
<tr> <tr>
<td>${deal.cpu}</td> <td>${row.cpu}</td>
<td>${deal.location}</td> <td>${row.location}</td>
<td>${deal.cores}</td> <td>${row.cores}</td>
<td>${deal.memory}</td> <td>${row.memory}</td>
<td>${deal.storage}</td> <td>${row.storage}</td>
<td>${deal.network}</td> <td>${row.network}</td>
<td>${deal.bandwidth}</td> <td>${row.bandwidth}</td>
<td class="price">${formatCurrency(deal.price, deal.currencySymbol)}</td> <td class="price">${formatCurrency(row.price, row.currencySymbol)}</td>
<td><a class="deploy-btn" href="${deal.orderUrl}" target="_blank" rel="noreferrer">Deploy now</a></td> <td><a class="deploy-btn" href="${row.orderUrl}" target="_blank" rel="noreferrer">Deploy now</a></td>
</tr> </tr>
`).join(''); `).join('');
}; };
const loadInstantDeals = async () => { const loadInstantDeals = async () => {
try { try {
const res = await fetch('/api/instant-offers', { cache: 'no-cache' }); const response = await fetch('/api/instant-offers', { cache: 'no-cache' });
const payload = await res.json(); const payload = await response.json();
renderInstantDeals(payload.offers || []); renderInstantDeals(payload.offers || []);
} catch (error) { } catch (error) {
console.error('Instant offers fetch failed', error); console.error('Instant offers error', error);
instantDealsBody.innerHTML = '<tr><td colspan="9" style="text-align:center;">Unable to load instant inventory.</td></tr>'; instantDealsBody.innerHTML = '<tr><td colspan="9" style="text-align:center;">Unable to load instant inventory.</td></tr>';
} }
}; };
const loadCustomOffers = async () => {
try {
const response = await fetch('/api/custom-offers', { cache: 'no-cache' });
const payload = await response.json();
offers = (payload.offers || []).map((offer, idx) => ({
...offer,
id: `custom-${idx}`,
basePrice: offer.basePrice || offer.price || 0,
currencySymbol: offer.currencySymbol || '€',
}));
} catch (error) {
console.error('Custom offers error', error);
offers = [];
}
renderCustomOffers();
};
const renderCustomOffers = () => { const renderCustomOffers = () => {
if (!offers.length) { if (!offers.length) {
customOffersList.innerHTML = '<p style="color: var(--text-muted);">No custom offers available right now.</p>'; customOffersList.innerHTML = '<p style="color: var(--text-muted);">No custom offers available.</p>';
return; return;
} }
customOffersList.innerHTML = offers.map((offer) => ` customOffersList.innerHTML = offers.map((offer) => `
@@ -531,10 +525,8 @@
renderCustomOptions(offer); renderCustomOptions(offer);
}); });
}); });
const firstCard = customOffersList.querySelector('.offer-card'); const first = customOffersList.querySelector('.offer-card');
if (firstCard) { if (first) first.click();
firstCard.click();
}
}; };
const renderCustomOptions = (offer) => { const renderCustomOptions = (offer) => {
@@ -542,25 +534,25 @@
customOptionsContainer.innerHTML = '<p style="color: var(--text-muted);">Dropdown data unavailable.</p>'; customOptionsContainer.innerHTML = '<p style="color: var(--text-muted);">Dropdown data unavailable.</p>';
return; return;
} }
const optionsHtml = offer.options.map((option, idx) => { const markup = offer.options.map((option, idx) => {
const selectId = `option-${offer.id}-${idx}`; const selectId = `option-${offer.id}-${idx}`;
const selects = option.choices.map((choice) => { const selector = option.choices.map((choice) => {
const label = choice.label || choice.rawLabel || 'Option';
const delta = Number(choice.priceDelta) || 0; const delta = Number(choice.priceDelta) || 0;
const symbol = choice.priceSymbol || offer.currencySymbol || '€'; const symbol = choice.priceSymbol || offer.currencySymbol || '€';
const deltaLabel = delta ? ` (${delta > 0 ? '+' : ''}${formatCurrency(delta, symbol)})` : ''; const deltaLabel = delta ? ` (${delta > 0 ? '+' : ''}${formatCurrency(delta, symbol)})` : '';
return `<option value="${delta}" data-option-label="${label}">${label}${deltaLabel}</option>`; const label = choice.label || choice.rawLabel || 'Option';
return `<option value="${delta}" data-label="${label}">${label}${deltaLabel}</option>`;
}).join(''); }).join('');
return ` return `
<div class="option-row"> <div class="option-row">
<label for="${selectId}">${option.label}</label> <label for="${selectId}">${option.label}</label>
<select id="${selectId}" data-option-name="${option.name}" data-option-label="${option.label}"> <select id="${selectId}" data-option-name="${option.name}" data-option-label="${option.label}">
${selects} ${selector}
</select> </select>
</div> </div>
`; `;
}).join(''); }).join('');
customOptionsContainer.innerHTML = `<div class="options-wrapper">${optionsHtml}</div>`; customOptionsContainer.innerHTML = `<div class="options-wrapper">${markup}</div>`;
customOptionsContainer.querySelectorAll('select').forEach((select) => { customOptionsContainer.querySelectorAll('select').forEach((select) => {
select.addEventListener('change', () => updateCustomTotals(offer)); select.addEventListener('change', () => updateCustomTotals(offer));
}); });
@@ -571,60 +563,41 @@
if (!offer) return; if (!offer) return;
const selects = customOptionsContainer.querySelectorAll('select'); const selects = customOptionsContainer.querySelectorAll('select');
let addonTotal = 0; let addonTotal = 0;
const detailLines = []; const details = [];
selects.forEach((select) => { selects.forEach((select) => {
const value = Number(select.value) || 0; const value = Number(select.value) || 0;
addonTotal += value; addonTotal += value;
const label = select.dataset.optionLabel || select.getAttribute('data-option-name'); const label = select.dataset.optionLabel || select.getAttribute('data-option-name');
if (value) { if (value !== 0) {
const symbol = offer.currencySymbol || '€'; const symbol = offer.currencySymbol || '€';
detailLines.push(`${label} (${value > 0 ? '+' : ''}${formatCurrency(value, symbol)})`); details.push(`${label} (${value > 0 ? '+' : ''}${formatCurrency(value, symbol)})`);
} }
}); });
const base = Number(offer.basePrice) || 0; const base = Number(offer.basePrice) || 0;
const total = base + addonTotal; const total = base + addonTotal;
const currency = offer.currencySymbol || '€'; const symbol = offer.currencySymbol || '€';
basePriceEl.textContent = formatCurrency(base, currency); basePriceEl.textContent = formatCurrency(base, symbol);
addonPriceEl.textContent = formatCurrency(addonTotal, currency); addonPriceEl.textContent = formatCurrency(addonTotal, symbol);
totalPriceEl.textContent = formatCurrency(total, currency); totalPriceEl.textContent = formatCurrency(total, symbol);
addonsSummaryEl.textContent = detailLines.length ? `Add-ons: ${detailLines.join(', ')}` : 'Add-ons: base configuration'; addonSummary.textContent = details.length ? `Add-ons: ${details.join(', ')}` : 'Add-ons: base configuration';
previewBtn.disabled = false; previewBtn.disabled = false;
previewBtn.onclick = () => { previewBtn.onclick = () => {
if (offer.configUrl) { if (offer.configUrl) window.open(offer.configUrl, '_blank');
window.open(offer.configUrl, '_blank');
}
}; };
}; };
const loadCustomOffers = async () => {
try {
const res = await fetch('/api/custom-offers', { cache: 'no-cache' });
const payload = await res.json();
offers = (payload.offers || []).map((offer, idx) => ({
...offer,
id: `custom-${idx}`,
basePrice: offer.basePrice || offer.price || 0,
currencySymbol: offer.currencySymbol || '€',
}));
} catch (error) {
console.error('Custom offers load failed', error);
offers = [];
}
renderCustomOffers();
};
const updateLatencies = () => { const updateLatencies = () => {
const list = [ const routes = [
{ id: 'latency-ams', base: 0.1 }, { id: 'latency-ams', base: 0.1 },
{ id: 'latency-rtm', base: 1.6 }, { id: 'latency-rtm', base: 1.6 },
{ id: 'latency-fra', base: 0.6 }, { id: 'latency-fra', base: 0.6 },
{ id: 'latency-lon', base: 0.2 }, { id: 'latency-lon', base: 0.2 },
{ id: 'latency-nyc', base: 0.9 }, { id: 'latency-nyc', base: 0.9 },
]; ];
list.forEach((item) => { routes.forEach((route) => {
const el = document.getElementById(item.id); const el = document.getElementById(route.id);
if (el) { if (el) {
const jitter = (item.base + (Math.random() - 0.5) * (item.base * 0.3 + 0.05)).toFixed(2); const jitter = (route.base + (Math.random() - 0.5) * (route.base * 0.3 + 0.05)).toFixed(2);
el.textContent = `${jitter}ms`; el.textContent = `${jitter}ms`;
} }
}); });