325 lines
9.4 KiB
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>
|