feat: UI polish, a11y, performance optimizations

- Payment summary card with prominent amount display
- "Bezahlt" stamp over dimmed QR code with TX details below
- Hide wallet/address buttons when paid, show only PDF
- URI box removed (was technical noise)
- Smart countdown: "29 Tage, 23 Std." instead of ticking seconds
- Dynamic page title for shared invoices
- Font fallbacks with size-adjust to prevent layout shifts
- Async Google Fonts loading, proper preconnect hints
- Deferred script loading (defer attribute)
- Minified JS (app.min.js, i18n.min.js)
- WCAG contrast fixes for badges and disabled button
- Footer link always underlined for a11y
- Translated aria-labels via data-i18n-aria
- i18n onChange callback for dynamic content updates
- Result card fade-in animation, responsive QR on mobile
This commit is contained in:
Alexander Schmidt
2026-03-25 16:50:55 +01:00
parent b8f2e24a42
commit 8bcdb33fa3
6 changed files with 133 additions and 46 deletions

65
app.js
View File

@@ -51,12 +51,34 @@
const downloadPdfBtn = $('#downloadPdf');
let cryptoLoaded = false;
let pdfLoaded = false;
let lastPaidData = null;
// --- Init ---
fetchRates();
loadFromHash() || loadSaved();
registerSW();
// Re-render dynamic texts on language change
I18n.onChange(function () {
// QR hint
var hint = qrContainer.querySelector('.qr-hint');
if (hint) hint.textContent = I18n.t('qr_hint');
// Paid stamp
var stamp = qrContainer.querySelector('.paid-stamp');
if (stamp) stamp.textContent = I18n.t('status_paid');
// Paid detail
if (lastPaidData) {
showPaidStatus(lastPaidData);
}
// Summary
if (resultSection.classList.contains('visible')) {
var xmrAmount = getXmrAmount();
var desc = descInput.value.trim();
buildSummary(xmrAmount, desc, selectedDays);
updatePageTitle(xmrAmount, desc);
}
});
// --- Events ---
addrInput.addEventListener('input', validateAddress);
amountInput.addEventListener('input', updateFiatHint);
@@ -792,17 +814,40 @@
function showPaidStatus(data) {
paymentStatus.className = 'payment-status paid';
var dateStr = '';
if (data.verified_at) {
var d = new Date(data.verified_at * 1000);
dateStr = ' — ' + d.toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', {
year: 'numeric', month: 'long', day: 'numeric'
});
// Stamp over QR + dim QR
qrContainer.classList.add('paid');
var existingStamp = qrContainer.querySelector('.paid-stamp');
if (!existingStamp) {
var stamp = document.createElement('div');
stamp.className = 'paid-stamp';
stamp.textContent = I18n.t('status_paid');
qrContainer.appendChild(stamp);
} else {
existingStamp.textContent = I18n.t('status_paid');
}
paymentStatus.innerHTML = '<div class="paid-badge">' + I18n.t('status_paid') +
'</div><div class="paid-detail">' + data.amount.toFixed(6) + ' XMR — TX ' +
data.tx_hash.substring(0, 8) + '...' + dateStr + '</div>';
// Hide proof section when paid
// Replace QR hint with payment detail
var hint = qrContainer.querySelector('.qr-hint');
if (hint) {
var dateStr = '';
if (data.verified_at) {
var d = new Date(data.verified_at * 1000);
dateStr = ' — ' + d.toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', {
year: 'numeric', month: 'long', day: 'numeric'
});
}
hint.textContent = data.amount.toFixed(6) + ' XMR — TX ' +
data.tx_hash.substring(0, 8) + '...' + dateStr;
hint.className = 'qr-hint paid-info';
}
paymentStatus.innerHTML = '';
lastPaidData = data;
// Hide unnecessary buttons when paid
openWalletBtn.style.display = 'none';
document.getElementById('copyAddr').style.display = 'none';
var proofSection = document.getElementById('proofSection');
if (proofSection) proofSection.style.display = 'none';
setPaidFavicon();

1
app.min.js vendored Normal file

File diff suppressed because one or more lines are too long

17
i18n.js
View File

@@ -32,6 +32,7 @@ var I18n = (function () {
pdf_footer: 'Erstellt mit xmrpay.link — Keine Registrierung, kein KYC',
qr_hint: 'Klick auf QR zum Speichern',
footer: 'Open Source &middot; Kein Backend &middot; Kein KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a>',
aria_currency: 'Währung',
label_uri_details: 'Monero-URI anzeigen',
label_share_link: 'Teilbarer Link',
btn_new_request: 'Neue Zahlungsanforderung',
@@ -78,6 +79,7 @@ var I18n = (function () {
pdf_footer: 'Created with xmrpay.link — No registration, no KYC',
qr_hint: 'Click QR to save',
footer: 'Open Source &middot; No Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a>',
aria_currency: 'Currency',
label_uri_details: 'Show Monero URI',
label_share_link: 'Shareable link',
btn_new_request: 'New payment request',
@@ -126,6 +128,9 @@ var I18n = (function () {
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
el.innerHTML = t[el.getAttribute('data-i18n-html')] || '';
});
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) {
el.setAttribute('aria-label', t[el.getAttribute('data-i18n-aria')] || '');
});
}
function apply(lang) {
@@ -144,6 +149,16 @@ var I18n = (function () {
document.querySelectorAll('.lang-option').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
});
// Notify listeners
for (var i = 0; i < onChangeCallbacks.length; i++) {
onChangeCallbacks[i](lang);
}
}
var onChangeCallbacks = [];
function onChange(fn) {
onChangeCallbacks.push(fn);
}
function buildDropdown() {
@@ -205,5 +220,5 @@ var I18n = (function () {
apply(currentLang);
});
return { t: t, apply: apply, getLang: getLang };
return { t: t, apply: apply, getLang: getLang, onChange: onChange };
})();

1
i18n.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,9 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
</head>
<body>
@@ -28,7 +29,7 @@
<label for="amount" data-i18n="label_amount"></label>
<div class="amount-row">
<input type="number" id="amount" placeholder="0.00" min="0" step="any">
<select id="currency">
<select id="currency" data-i18n-aria="aria_currency">
<option value="XMR">XMR</option>
<option value="EUR" selected>EUR</option>
<option value="USD">USD</option>
@@ -59,15 +60,13 @@
<div id="result" class="card">
<div class="qr-container" id="qr"></div>
<div class="payment-summary" id="paymentSummary"></div>
<div class="payment-status" id="paymentStatus"></div>
<div class="countdown" id="countdown"></div>
<details class="uri-details">
<summary data-i18n="label_uri_details"></summary>
<div class="uri-box" id="uri"></div>
</details>
<div class="uri-box" id="uri" style="display:none"></div>
<div class="share-link-box" id="shareLinkBox">
<label data-i18n="label_share_link"></label>
<div class="share-link-row">
<input type="text" id="shareLink" readonly>
<input type="text" id="shareLink" readonly data-i18n-aria="label_share_link">
<button class="btn btn-secondary btn-icon" id="copyShareLink" title="Copy">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
@@ -100,10 +99,6 @@
<div class="proof-result" id="proofResult"></div>
</div>
</div>
<!-- Payment status (shown when proof is stored) -->
<div class="payment-status" id="paymentStatus"></div>
<button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request"></button>
</div>
</main>
@@ -126,8 +121,8 @@
<div class="toast" id="toast"></div>
<script src="lib/qrcode.min.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
<script src="lib/qrcode.min.js" defer></script>
<script src="i18n.min.js" defer></script>
<script src="app.min.js" defer></script>
</body>
</html>

View File

@@ -1,3 +1,21 @@
@font-face {
font-family: 'Inter fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 25%;
line-gap-override: 0%;
}
@font-face {
font-family: 'JetBrains Mono fallback';
src: local('Courier New');
size-adjust: 112%;
ascent-override: 78%;
descent-override: 22%;
line-gap-override: 0%;
}
:root {
--bg: #0d0d0d;
--bg-card: #1a1a1a;
@@ -10,8 +28,8 @@
--success: #4caf50;
--error: #f44336;
--radius: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
--font: 'Inter', 'Inter fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'JetBrains Mono fallback', 'Fira Code', monospace;
}
* {
@@ -133,6 +151,7 @@ main {
width: 100%;
max-width: 480px;
padding: 1rem;
flex: 1;
}
.card {
@@ -247,7 +266,7 @@ input.valid {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
color: #aaa;
font-family: var(--font);
font-size: 0.8rem;
font-weight: 500;
@@ -301,7 +320,7 @@ textarea {
}
.btn-primary:disabled {
opacity: 0.4;
opacity: 0.5;
cursor: not-allowed;
}
@@ -532,24 +551,39 @@ textarea {
.payment-status.paid {
display: block;
text-align: center;
margin-top: 1rem;
}
.paid-badge {
display: inline-block;
background: var(--success);
color: #fff;
padding: 0.4rem 1.2rem;
border-radius: var(--radius);
font-weight: 700;
font-size: 1rem;
margin-bottom: 0.3rem;
.paid-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-12deg);
border: 4px solid var(--success);
border-radius: 12px;
padding: 0.4rem 1.5rem;
font-size: 1.8rem;
font-weight: 900;
color: var(--success);
background: rgba(0, 0, 0, 0.75);
text-transform: uppercase;
letter-spacing: 3px;
pointer-events: none;
z-index: 2;
}
.paid-detail {
.qr-container.paid canvas,
.qr-container.paid img {
opacity: 0.3;
}
.qr-container {
position: relative;
}
.paid-info {
color: var(--success);
font-size: 0.75rem;
color: var(--text-muted);
font-family: var(--mono);
}
.btn-new {
@@ -594,10 +628,6 @@ footer {
footer a {
color: var(--accent);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}