perf: 100% Lighthouse score — contrast, CLS, caching fixes
- Font preload eliminates layout shift (CLS 0) - Dual accent colors: --accent for filled buttons, --accent-text for text on dark bg - All WCAG AA contrast ratios met (buttons, links, badges, countdown) - Language picker: position absolute instead of fixed - CoinGecko rates proxied with 2min server-side cache (no CORS, no rate limit) - English as default inline text (no empty→text shift)
This commit is contained in:
@@ -1,9 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Server-side cache: fetch from CoinGecko at most once per 2 minutes
|
||||||
|
$cacheFile = __DIR__ . '/../data/rates_cache.json';
|
||||||
|
$cacheTTL = 120; // seconds
|
||||||
|
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||||
|
if ($cached && (time() - ($cached['_time'] ?? 0)) < $cacheTTL) {
|
||||||
|
unset($cached['_time']);
|
||||||
header('Cache-Control: public, max-age=60');
|
header('Cache-Control: public, max-age=60');
|
||||||
|
echo json_encode($cached);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=eur,usd,chf';
|
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=eur,usd,chf';
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
@@ -15,8 +27,27 @@ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($response !== false && $httpCode === 200) {
|
if ($response !== false && $httpCode === 200) {
|
||||||
echo $response;
|
$data = json_decode($response, true);
|
||||||
} else {
|
if ($data) {
|
||||||
|
$data['_time'] = time();
|
||||||
|
file_put_contents($cacheFile, json_encode($data));
|
||||||
|
unset($data['_time']);
|
||||||
|
header('Cache-Control: public, max-age=60');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On error, serve stale cache if available
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
$cached = json_decode(file_get_contents($cacheFile), true);
|
||||||
|
if ($cached) {
|
||||||
|
unset($cached['_time']);
|
||||||
|
header('Cache-Control: public, max-age=30');
|
||||||
|
echo json_encode($cached);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http_response_code(502);
|
http_response_code(502);
|
||||||
echo json_encode(['error' => 'Failed to fetch rates']);
|
echo json_encode(['error' => 'Failed to fetch rates']);
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>xmrpay.link — Monero Invoice Generator</title>
|
<title>xmrpay.link — Monero Invoice Generator</title>
|
||||||
<meta name="description" content="Create Monero payment requests in seconds. No account, no backend, no KYC.">
|
<meta name="description" content="Create Monero payment requests in seconds. No account, no backend, no KYC.">
|
||||||
<link rel="icon" id="favicon" href="favicon.svg" type="image/svg+xml">
|
<link rel="icon" id="favicon" href="favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="preload" href="fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
19
style.css
19
style.css
@@ -23,8 +23,9 @@
|
|||||||
--border: #333;
|
--border: #333;
|
||||||
--text: #e0e0e0;
|
--text: #e0e0e0;
|
||||||
--text-muted: #888;
|
--text-muted: #888;
|
||||||
--accent: #ff6600;
|
--accent: #c74a00;
|
||||||
--accent-hover: #ff8533;
|
--accent-hover: #a83f00;
|
||||||
|
--accent-text: #e87830;
|
||||||
--success: #4caf50;
|
--success: #4caf50;
|
||||||
--error: #f44336;
|
--error: #f44336;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
@@ -54,7 +55,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lang-picker {
|
.lang-picker {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0.75rem;
|
top: 0.75rem;
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -119,7 +120,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lang-option.active {
|
.lang-option.active {
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
@@ -138,7 +139,7 @@ header h1 a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header h1 span {
|
header h1 span {
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
header p {
|
||||||
@@ -590,8 +591,8 @@ textarea {
|
|||||||
.btn-new {
|
.btn-new {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent-text);
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-new:hover {
|
.btn-new:hover {
|
||||||
@@ -628,7 +629,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
footer a {
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +645,7 @@ footer a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.countdown.active {
|
.countdown.active {
|
||||||
color: var(--accent);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user