pablohere/public/btc-premium.html

325 lines
9.4 KiB
HTML

<!DOCTYPE html>
<html lang="ca">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calculadora de compravenda de bitcoin</title>
<style>
:root {
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
body {
max-width: 420px;
margin: 4rem auto;
padding: 0 1rem;
line-height: 1.5;
}
h1 {
text-align: center;
font-weight: 500;
font-size: 1.15rem;
margin-bottom: 0.25rem;
}
.sub {
text-align: center;
font-size: 0.85rem;
color: #888;
margin: 0 0 2rem;
}
.field {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
label {
flex: 0 0 5.5rem;
font-size: 0.9rem;
color: #888;
}
.input-wrap {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
input {
flex: 1;
width: 100%;
box-sizing: border-box;
font-size: 1.25rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.5rem 3rem 0.5rem 0.6rem;
border: 1px solid rgba(128, 128, 128, 0.4);
border-radius: 8px;
background: transparent;
color: inherit;
}
input:focus {
outline: none;
border-color: #f7931a;
}
.unit {
position: absolute;
right: 0.7rem;
font-size: 0.8rem;
color: #888;
pointer-events: none;
}
input.computed {
border-style: dashed;
color: #f7931a;
}
.rates {
display: grid;
grid-template-columns: auto auto;
align-items: baseline;
justify-content: center;
column-gap: 0.4rem;
row-gap: 0.15rem;
margin: -1rem 0 2rem;
font-size: 0.8rem;
color: #999;
cursor: default;
user-select: none;
}
.rates .lbl {
text-align: right;
}
.rates .value {
text-align: left;
font-variant-numeric: tabular-nums;
}
.rates .value.trade-val {
color: #f7931a;
}
.rates .cue {
grid-column: 1 / -1;
text-align: center;
font-size: 0.7rem;
font-style: italic;
color: #999;
opacity: 0;
transition: opacity 0.15s;
}
.rates.is-same .value.trade-val {
color: #999;
}
.rates.is-same .cue {
opacity: 1;
}
.tag {
flex: 0 0 2.5rem;
font-size: 0.7rem;
color: #f7931a;
text-transform: uppercase;
letter-spacing: 0.05em;
visibility: hidden;
}
.field.is-computed .tag {
visibility: visible;
}
</style>
</head>
<body>
<h1>Calculadora de compravenda de bitcoin</h1>
<p class="sub">EUR ⇄ BTC, amb el premium inclòs</p>
<div class="rates">
<span class="lbl" title="Preu de mercat (mediana) — s'usa pel càlcul, no és editable">mercat</span>
<span class="value" id="rate"></span>
<span class="cue" id="same">a preu de mercat</span>
<span class="lbl" title="Preu de mercat ajustat amb el teu premium — el preu real de la compravenda">operació</span>
<span class="value trade-val" id="trade"></span>
</div>
<div class="field" id="field-eur">
<label for="eur">EUR</label>
<span class="input-wrap">
<input id="eur" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00" value="1000">
<span class="unit"></span>
</span>
<span class="tag">auto</span>
</div>
<div class="field" id="field-sats">
<label for="sats">Bitcoin</label>
<span class="input-wrap">
<input id="sats" type="text" inputmode="numeric" autocomplete="off" placeholder="0">
<span class="unit">sats</span>
</span>
<span class="tag">auto</span>
</div>
<div class="field" id="field-premium">
<label for="premium">Premium</label>
<span class="input-wrap">
<input id="premium" type="text" inputmode="decimal" autocomplete="off" placeholder="0" value="5">
<span class="unit">%</span>
</span>
<span class="tag">auto</span>
</div>
<script>
const SATS_PER_BTC = 100_000_000;
const REFRESH_MS = 5 * 60 * 1000;
// --- hidden price engine: median BTC/EUR across several exchanges ---
// The median holds as long as at least MIN_SOURCES quotes come back valid;
// any exchange that errors, times out, or returns junk is simply dropped.
const MIN_SOURCES = 3;
const SOURCES = [
{
url: "https://api.kraken.com/0/public/Ticker?pair=XBTEUR",
parse: (d) => parseFloat(d.result.XXBTZEUR.c[0]),
},
{
url: "https://api.coinbase.com/v2/prices/BTC-EUR/spot",
parse: (d) => parseFloat(d.data.amount),
},
{
url: "https://www.bitstamp.net/api/v2/ticker/btceur/",
parse: (d) => parseFloat(d.last),
},
{
url: "https://api-pub.bitfinex.com/v2/ticker/tBTCEUR",
parse: (d) => parseFloat(d[6]), // LAST_PRICE
},
{
url: "https://api.bitvavo.com/v2/ticker/price?market=BTC-EUR",
parse: (d) => parseFloat(d.price),
},
];
let price = null; // EUR per BTC, median
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
// fetch one exchange, resolving to a valid positive number or null
async function fetchQuote(source) {
try {
const data = await (await fetch(source.url)).json();
const value = source.parse(data);
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
}
async function refreshPrice() {
const quotes = (await Promise.all(SOURCES.map(fetchQuote))).filter(
(v) => v !== null
);
if (quotes.length < MIN_SOURCES) return; // too few sources; keep last price
price = median(quotes);
document.getElementById("rate").textContent =
"€" + Math.round(price).toLocaleString("en-US") + " / BTC";
recompute(null); // keep the derived field in sync with the new price
}
// --- calculator ---
const els = {
eur: document.getElementById("eur"),
sats: document.getElementById("sats"),
premium: document.getElementById("premium"),
};
const fields = {
eur: document.getElementById("field-eur"),
sats: document.getElementById("field-sats"),
premium: document.getElementById("field-premium"),
};
// recency of user edits; last entry is the field we derive.
// defaults: EUR (1000) and premium (5%) given, so sats is derived.
let touchOrder = ["premium", "eur", "sats"];
const parse = (el) => {
const v = parseFloat(el.value.replace(/[^0-9.\-]/g, ""));
return isFinite(v) ? v : null;
};
const fmt = {
eur: (v) => v.toFixed(2),
sats: (v) => Math.round(v).toLocaleString("en-US"),
premium: (v) => parseFloat(v.toFixed(2)).toString(),
};
function recompute(edited) {
if (edited) {
touchOrder = [edited, ...touchOrder.filter((f) => f !== edited)];
}
const target = touchOrder[touchOrder.length - 1];
// highlight which field is currently derived
for (const f in fields) {
fields[f].classList.toggle("is-computed", f === target);
els[f].classList.toggle("computed", f === target);
}
if (price === null) return;
const eur = parse(els.eur);
const sats = parse(els.sats);
const premium = parse(els.premium);
let result = null;
if (target === "eur" && sats !== null && premium !== null) {
result = (sats / SATS_PER_BTC) * price * (1 + premium / 100);
} else if (target === "sats" && eur !== null && premium !== null) {
const effective = price * (1 + premium / 100);
if (effective > 0) result = (eur / effective) * SATS_PER_BTC;
} else if (target === "premium" && eur !== null && sats !== null) {
const base = (sats / SATS_PER_BTC) * price;
if (base > 0) result = (eur / base - 1) * 100;
}
els[target].value = result === null ? "" : fmt[target](result);
// effective trade price = market adjusted by the current premium
// (use the just-derived premium when premium is the computed field)
const effPremium = target === "premium" ? result : premium;
const effective = price * (1 + (effPremium ?? 0) / 100);
const tradeLine = document.querySelector(".rates");
document.getElementById("trade").textContent =
"€" + Math.round(effective).toLocaleString("en-US") + " / BTC";
tradeLine.classList.toggle(
"is-same",
Math.round(effective) === Math.round(price)
);
}
// reformat a field once the user is done entering it
const formatField = (f) => {
const v = parse(els[f]);
if (v !== null) els[f].value = fmt[f](v);
};
for (const f in els) {
els[f].addEventListener("input", () => {
// sats are whole numbers only — drop anything but digits as typed
if (f === "sats") els.sats.value = els.sats.value.replace(/[^0-9]/g, "");
recompute(f);
});
els[f].addEventListener("blur", () => formatField(f));
}
// normalize the pre-filled defaults (e.g. "1000" -> "1000.00")
formatField("eur");
formatField("premium");
recompute(null);
refreshPrice();
setInterval(refreshPrice, REFRESH_MS);
</script>
</body>
</html>