feat: complete v1 — QR invoice generator with i18n, short URLs, offline support

- XMR address validation (standard, subaddress, integrated)
- Amount in XMR/EUR/USD/CHF with CoinGecko conversion
- QR code generation with monero: URI
- Shareable short URLs (/s/abc123) via self-hosted PHP backend
- i18n (DE/EN) with browser language detection
- Service worker for offline capability
- Dark mode, responsive design
This commit is contained in:
Alexander Schmidt
2026-03-24 16:38:44 +01:00
parent 5a088f595b
commit bd796e46dc
9 changed files with 1190 additions and 17 deletions

View File

@@ -52,16 +52,20 @@ Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann.
## Feature-Roadmap ## Feature-Roadmap
### v1 — Der Kern (Static QR Generator) ### v1 — Der Kern (Static QR Generator)
- [ ] XMR-Adresse eingeben (mit Validierung) - [x] XMR-Adresse eingeben (Validierung: Standard, Subaddress, Integrated)
- [ ] Betrag in XMR eingeben (optional: EUR/CHF/USD-Umrechnung via CoinGecko API) - [x] Betrag in XMR eingeben (optional: EUR/CHF/USD-Umrechnung via CoinGecko API)
- [ ] Beschreibung / Verwendungszweck - [x] Beschreibung / Verwendungszweck
- [ ] Optionaler Countdown-Timer (Zahlungsfrist) - [x] Optionaler Countdown-Timer (Zahlungsfrist)
- [ ] `monero:`-URI generieren (Standard: [SLIP-0021](https://github.com/satoshilabs/slips/blob/master/slip-0021.md)) - [x] `monero:`-URI generieren
- [ ] QR-Code anzeigen und als PNG downloaden - [x] QR-Code anzeigen und als PNG downloaden
- [ ] Link kopieren (für Messenger, E-Mail etc.) - [x] Link kopieren (für Messenger, E-Mail etc.)
- [ ] Responsive Design, Dark Mode - [x] Teilbare Kurz-URLs (`/s/abc123`) — selbst gehostetes URL-Shortening
- [x] Mehrsprachigkeit (DE, EN) mit automatischer Browsererkennung
- [x] Responsive Design, Dark Mode
- [x] Offline-fähig via Service Worker
- [x] CoinGecko-Fallback mit Auto-Retry
### v2 — View-Key Zahlungsbestätigung (Browser-basiert) ### v2 — View-Key Zahlungsbestätigung (Browser-basiert)
@@ -75,7 +79,7 @@ Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann.
- [ ] PDF-Rechnung generieren (Logo, Betrag in Fiat, XMR-Betrag, QR, Fälligkeitsdatum) - [ ] PDF-Rechnung generieren (Logo, Betrag in Fiat, XMR-Betrag, QR, Fälligkeitsdatum)
- [ ] Einbettbarer `<iframe>`-Widget für beliebige Websites - [ ] Einbettbarer `<iframe>`-Widget für beliebige Websites
- [ ] Mehrsprachigkeit (DE, EN, FR, ES) - [ ] Weitere Sprachen (FR, ES, ...)
- [ ] Rechnungshistorie (LocalStorage, exportierbar als CSV) - [ ] Rechnungshistorie (LocalStorage, exportierbar als CSV)
- [ ] „Pay Button" Generator (HTML-Snippet zum Einbetten) - [ ] „Pay Button" Generator (HTML-Snippet zum Einbetten)
@@ -135,14 +139,16 @@ um ihn mündlich weiterzugeben.
``` ```
xmrpay.link/ xmrpay.link/
├── index.html # Single-Page-App Entry Point ├── index.html # Single-Page-App Entry Point
├── app.js # Haupt-Logik (URI-Builder, QR, Fiat-Kurs) ├── app.js # Haupt-Logik (URI-Builder, QR, Fiat-Kurs, Short-URLs)
├── monitor.js # View-Key Monitoring (v2) ├── i18n.js # Mehrsprachigkeit (DE, EN)
├── invoice.js # PDF-Generierung (v3) ├── style.css # Dark Theme, Responsive
├── style.css ├── sw.js # Service Worker (Offline-Fähigkeit)
├── s.php # Kurz-URL Redirect
├── api/
│ └── shorten.php # Kurz-URL Erstellung
├── data/ # Kurz-URL Speicher (JSON, auto-generiert)
├── lib/ ├── lib/
── qrcode.min.js ── qrcode.min.js # QR-Code Generator
│ ├── monero.js # monero-javascript WASM build
│ └── jspdf.min.js
├── README.md ├── README.md
└── LICENSE # MIT └── LICENSE # MIT
``` ```

61
api/shorten.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$dataDir = __DIR__ . '/../data';
$dbFile = $dataDir . '/urls.json';
if (!is_dir($dataDir)) {
mkdir($dataDir, 0750, true);
}
$input = json_decode(file_get_contents('php://input'), true);
$hash = $input['hash'] ?? '';
if (empty($hash) || strlen($hash) > 500) {
http_response_code(400);
echo json_encode(['error' => 'Invalid data']);
exit;
}
// Load existing URLs
$urls = [];
if (file_exists($dbFile)) {
$urls = json_decode(file_get_contents($dbFile), true) ?: [];
}
// Check if this hash already exists
$existing = array_search($hash, $urls);
if ($existing !== false) {
echo json_encode(['code' => $existing]);
exit;
}
// Generate short code (6 chars)
function generateCode($length = 6) {
$chars = 'abcdefghijkmnpqrstuvwxyz23456789';
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return $code;
}
$code = generateCode();
while (isset($urls[$code])) {
$code = generateCode();
}
$urls[$code] = $hash;
file_put_contents($dbFile, json_encode($urls, JSON_UNESCAPED_UNICODE), LOCK_EX);
echo json_encode(['code' => $code]);

344
app.js Normal file
View File

@@ -0,0 +1,344 @@
(function () {
'use strict';
// --- Config ---
const COINGECKO_API = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=eur,usd,chf';
// Standard address (4..., 95 chars), Subaddress (8..., 95 chars), Integrated address (4..., 106 chars)
const XMR_STANDARD_REGEX = /^[48][1-9A-HJ-NP-Za-km-z]{94}$/;
const XMR_INTEGRATED_REGEX = /^4[1-9A-HJ-NP-Za-km-z]{105}$/;
const CACHE_DURATION = 60000; // 1 min
const RATE_RETRY_DELAY = 10000; // 10s retry on failure
// --- State ---
let fiatRates = null;
let ratesTimestamp = 0;
let countdownInterval = null;
let ratesFailed = false;
// --- DOM ---
const $ = (s) => document.querySelector(s);
const addrInput = $('#addr');
const amountInput = $('#amount');
const currencySelect = $('#currency');
const descInput = $('#desc');
const timerInput = $('#timer');
const generateBtn = $('#generate');
const resultSection = $('#result');
const qrContainer = $('#qr');
const uriBox = $('#uri');
const copyUriBtn = $('#copyUri');
const copyAddrBtn = $('#copyAddr');
const downloadBtn = $('#downloadQr');
const countdownEl = $('#countdown');
const fiatHint = $('#fiatHint');
const toast = $('#toast');
const shareLinkInput = $('#shareLink');
const copyShareLinkBtn = $('#copyShareLink');
const newRequestBtn = $('#newRequest');
const homeLink = $('#homeLink');
// --- Init ---
fetchRates();
loadFromHash() || loadSaved();
registerSW();
// --- Events ---
addrInput.addEventListener('input', validateAddress);
amountInput.addEventListener('input', updateFiatHint);
currencySelect.addEventListener('change', updateFiatHint);
generateBtn.addEventListener('click', generate);
copyUriBtn.addEventListener('click', () => copyToClipboard(uriBox.textContent));
copyAddrBtn.addEventListener('click', () => copyToClipboard(addrInput.value.trim()));
copyShareLinkBtn.addEventListener('click', () => copyToClipboard(shareLinkInput.value));
downloadBtn.addEventListener('click', downloadQR);
newRequestBtn.addEventListener('click', resetForm);
homeLink.addEventListener('click', function (e) { e.preventDefault(); resetForm(); });
// --- Functions ---
function resetForm() {
addrInput.value = '';
amountInput.value = '';
currencySelect.value = 'EUR';
descInput.value = '';
timerInput.value = '';
fiatHint.textContent = '';
fiatHint.classList.remove('error');
addrInput.classList.remove('valid', 'invalid');
generateBtn.disabled = true;
resultSection.classList.remove('visible');
if (countdownInterval) clearInterval(countdownInterval);
qrContainer.innerHTML = '';
uriBox.textContent = '';
shareLinkInput.value = '';
history.replaceState(null, '', location.pathname);
window.scrollTo({ top: 0, behavior: 'smooth' });
addrInput.focus();
}
function isValidAddress(addr) {
return XMR_STANDARD_REGEX.test(addr) || XMR_INTEGRATED_REGEX.test(addr);
}
function validateAddress() {
const val = addrInput.value.trim();
addrInput.classList.remove('valid', 'invalid');
if (val.length === 0) return;
if (isValidAddress(val)) {
addrInput.classList.add('valid');
} else if (val.length >= 10) {
addrInput.classList.add('invalid');
}
updateGenerateBtn();
}
function updateGenerateBtn() {
const addr = addrInput.value.trim();
generateBtn.disabled = !isValidAddress(addr);
}
function updateFiatHint() {
const amount = parseFloat(amountInput.value);
const currency = currencySelect.value;
if (!amount || amount <= 0) {
fiatHint.textContent = '';
fiatHint.classList.remove('error');
return;
}
if (currency !== 'XMR' && !fiatRates) {
fiatHint.textContent = ratesFailed ? I18n.t('rates_offline') : '';
fiatHint.classList.toggle('error', ratesFailed);
return;
}
fiatHint.classList.remove('error');
if (currency === 'XMR') {
if (fiatRates) {
const eur = (amount * fiatRates.eur).toFixed(2);
fiatHint.textContent = '\u2248 ' + eur + ' EUR';
} else {
fiatHint.textContent = '';
}
} else {
const rate = fiatRates[currency.toLowerCase()];
if (rate && rate > 0) {
const xmr = (amount / rate).toFixed(8);
fiatHint.textContent = '\u2248 ' + xmr + ' XMR';
}
}
}
function getXmrAmount() {
const amount = parseFloat(amountInput.value);
const currency = currencySelect.value;
if (!amount || amount <= 0) return null;
if (currency === 'XMR') return amount;
if (fiatRates) {
const rate = fiatRates[currency.toLowerCase()];
if (rate && rate > 0) return amount / rate;
}
return null;
}
function buildUri(addr, xmrAmount, desc) {
let uri = 'monero:' + addr;
const params = [];
if (xmrAmount) params.push('tx_amount=' + xmrAmount.toFixed(12));
if (desc) params.push('tx_description=' + encodeURIComponent(desc));
if (params.length) uri += '?' + params.join('&');
return uri;
}
function buildHash(addr, xmrAmount, desc, timer) {
const params = new URLSearchParams();
params.set('a', addr);
if (xmrAmount) params.set('x', xmrAmount.toFixed(12));
if (desc) params.set('d', desc);
if (timer) params.set('t', timer);
return params.toString();
}
async function shortenUrl(hash) {
try {
const res = await fetch('/api/shorten.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: hash })
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
return location.origin + '/s/' + data.code;
} catch (e) {
console.warn('Short URL failed:', e);
return null;
}
}
function generate() {
const addr = addrInput.value.trim();
if (!isValidAddress(addr)) return;
const xmrAmount = getXmrAmount();
const desc = descInput.value.trim();
const timer = parseInt(timerInput.value) || 0;
const uri = buildUri(addr, xmrAmount, desc);
// Show result
resultSection.classList.add('visible');
uriBox.textContent = uri;
// Share link — show long URL immediately, then replace with short
const hash = buildHash(addr, xmrAmount, desc, timer);
shareLinkInput.value = location.origin + '/#' + hash;
shortenUrl(hash).then(function (shortUrl) {
if (shortUrl) shareLinkInput.value = shortUrl;
});
// QR
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: uri,
width: 256,
height: 256,
colorDark: '#ffffff',
colorLight: '#1a1a1a',
correctLevel: QRCode.CorrectLevel.M
});
// Countdown
startCountdown();
// Save to LocalStorage
saveState(addr);
// Scroll to result
resultSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function loadFromHash() {
const hash = location.hash.substring(1);
if (!hash) return false;
const params = new URLSearchParams(hash);
const addr = params.get('a');
if (!addr || !isValidAddress(addr)) return false;
addrInput.value = addr;
validateAddress();
const xmr = params.get('x');
if (xmr) {
amountInput.value = parseFloat(xmr);
currencySelect.value = 'XMR';
}
const desc = params.get('d');
if (desc) descInput.value = desc;
const timer = params.get('t');
if (timer && parseInt(timer) > 0) timerInput.value = timer;
// Auto-generate
setTimeout(generate, 100);
return true;
}
function startCountdown() {
if (countdownInterval) clearInterval(countdownInterval);
countdownEl.textContent = '';
countdownEl.className = 'countdown';
const minutes = parseInt(timerInput.value);
if (!minutes || minutes <= 0) return;
const end = Date.now() + minutes * 60000;
countdownEl.classList.add('active');
function tick() {
const remaining = end - Date.now();
if (remaining <= 0) {
clearInterval(countdownInterval);
countdownEl.textContent = I18n.t('countdown_expired');
countdownEl.className = 'countdown expired';
return;
}
const m = Math.floor(remaining / 60000);
const s = Math.floor((remaining % 60000) / 1000);
countdownEl.textContent = I18n.t('countdown_remaining') + pad(m) + ':' + pad(s);
}
tick();
countdownInterval = setInterval(tick, 1000);
}
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function downloadQR() {
const canvas = qrContainer.querySelector('canvas');
if (!canvas) return;
const link = document.createElement('a');
link.download = 'xmrpay-qr.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast(I18n.t('toast_copied'));
});
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2000);
}
function saveState(addr) {
try {
localStorage.setItem('xmrpay_addr', addr);
} catch (e) { /* silent */ }
}
function loadSaved() {
try {
const addr = localStorage.getItem('xmrpay_addr');
if (addr) {
addrInput.value = addr;
validateAddress();
}
} catch (e) { /* silent */ }
}
async function fetchRates() {
if (fiatRates && Date.now() - ratesTimestamp < CACHE_DURATION) return;
try {
const res = await fetch(COINGECKO_API);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
fiatRates = data.monero;
ratesTimestamp = Date.now();
ratesFailed = false;
updateFiatHint();
} catch (e) {
console.warn('Kurse konnten nicht geladen werden:', e);
ratesFailed = true;
updateFiatHint();
setTimeout(fetchRates, RATE_RETRY_DELAY);
}
}
function registerSW() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
}
})();

159
i18n.js Normal file
View File

@@ -0,0 +1,159 @@
var I18n = (function () {
'use strict';
var languages = {
de: { name: 'Deutsch', flag: 'DE' },
en: { name: 'English', flag: 'EN' }
};
var translations = {
de: {
subtitle: 'Monero-Zahlungsanforderung in Sekunden',
label_addr: 'XMR-Adresse',
placeholder_addr: '4...',
label_amount: 'Betrag',
label_desc: 'Beschreibung (optional)',
placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...',
label_timer: 'Zahlungsfrist in Minuten (optional)',
placeholder_timer: 'z.B. 30',
btn_generate: 'Zahlungsanforderung erstellen',
btn_copy_link: 'Link kopieren',
btn_copy_addr: 'Adresse kopieren',
btn_download_qr: 'QR speichern',
footer: 'Open Source &middot; Kein Backend &middot; Kein KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a>',
label_share_link: 'Teilbarer Link',
btn_new_request: 'Neue Zahlungsanforderung',
toast_copied: 'Kopiert!',
countdown_expired: 'Zahlungsfrist abgelaufen',
countdown_remaining: 'Zahlungsfrist: ',
rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich'
},
en: {
subtitle: 'Monero payment request in seconds',
label_addr: 'XMR Address',
placeholder_addr: '4...',
label_amount: 'Amount',
label_desc: 'Description (optional)',
placeholder_desc: 'e.g. Invoice #42, freelance work...',
label_timer: 'Payment deadline in minutes (optional)',
placeholder_timer: 'e.g. 30',
btn_generate: 'Create payment request',
btn_copy_link: 'Copy link',
btn_copy_addr: 'Copy address',
btn_download_qr: 'Save QR',
footer: 'Open Source &middot; No Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a>',
label_share_link: 'Shareable link',
btn_new_request: 'New payment request',
toast_copied: 'Copied!',
countdown_expired: 'Payment deadline expired',
countdown_remaining: 'Payment deadline: ',
rates_offline: 'Rates unavailable — XMR amount only'
}
};
var currentLang = 'de';
function detectLang() {
var saved = null;
try { saved = localStorage.getItem('xmrpay_lang'); } catch (e) {}
if (saved && translations[saved]) return saved;
var navLangs = navigator.languages || [navigator.language || 'de'];
for (var i = 0; i < navLangs.length; i++) {
var code = navLangs[i].substring(0, 2).toLowerCase();
if (translations[code]) return code;
}
return 'de';
}
function applyDOM(t) {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = t[el.getAttribute('data-i18n')] || '';
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
el.placeholder = t[el.getAttribute('data-i18n-placeholder')] || '';
});
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
el.innerHTML = t[el.getAttribute('data-i18n-html')] || '';
});
}
function apply(lang) {
currentLang = lang;
var t = translations[lang];
document.documentElement.lang = lang;
try { localStorage.setItem('xmrpay_lang', lang); } catch (e) {}
applyDOM(t);
// Update toggle label
var cur = document.getElementById('langCurrent');
if (cur) cur.textContent = languages[lang].flag;
// Update dropdown active state
document.querySelectorAll('.lang-option').forEach(function (btn) {
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
});
}
function buildDropdown() {
var dropdown = document.getElementById('langDropdown');
if (!dropdown) return;
dropdown.innerHTML = '';
var keys = Object.keys(languages);
for (var i = 0; i < keys.length; i++) {
var code = keys[i];
var btn = document.createElement('button');
btn.className = 'lang-option';
btn.setAttribute('data-lang', code);
btn.textContent = languages[code].name;
if (code === currentLang) btn.classList.add('active');
btn.addEventListener('click', (function (c) {
return function () {
apply(c);
closePicker();
};
})(code));
dropdown.appendChild(btn);
}
}
function closePicker() {
var picker = document.getElementById('langPicker');
if (picker) picker.classList.remove('open');
}
function initPicker() {
var picker = document.getElementById('langPicker');
var toggle = document.getElementById('langToggle');
if (!picker || !toggle) return;
toggle.addEventListener('click', function (e) {
e.stopPropagation();
picker.classList.toggle('open');
});
document.addEventListener('click', function (e) {
if (!picker.contains(e.target)) closePicker();
});
}
function t(key) {
return (translations[currentLang] && translations[currentLang][key]) || key;
}
function getLang() {
return currentLang;
}
// Init
currentLang = detectLang();
document.addEventListener('DOMContentLoaded', function () {
buildDropdown();
initPicker();
apply(currentLang);
});
return { t: t, apply: apply, getLang: getLang };
})();

98
index.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xmrpay.link — Monero Invoice Generator</title>
<meta name="description" content="Create Monero payment requests in seconds. No account, no backend, no KYC.">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛏️</text></svg>">
<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="stylesheet" href="style.css">
</head>
<body>
<header>
<h1><a href="/" id="homeLink">xmr<span>pay</span>.link</a></h1>
<p data-i18n="subtitle"></p>
</header>
<main>
<div class="card">
<div class="field">
<label for="addr" data-i18n="label_addr"></label>
<input type="text" id="addr" data-i18n-placeholder="placeholder_addr" spellcheck="false" autocomplete="off">
</div>
<div class="field">
<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">
<option value="XMR">XMR</option>
<option value="EUR" selected>EUR</option>
<option value="USD">USD</option>
<option value="CHF">CHF</option>
</select>
</div>
<div class="fiat-hint" id="fiatHint"></div>
</div>
<div class="field">
<label for="desc" data-i18n="label_desc"></label>
<textarea id="desc" data-i18n-placeholder="placeholder_desc"></textarea>
</div>
<div class="field">
<label for="timer" data-i18n="label_timer"></label>
<input type="number" id="timer" data-i18n-placeholder="placeholder_timer" min="1" step="1">
</div>
<button class="btn btn-primary" id="generate" disabled data-i18n="btn_generate"></button>
</div>
<div id="result" class="card">
<div class="qr-container" id="qr"></div>
<div class="countdown" id="countdown"></div>
<div class="uri-box" id="uri"></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>
<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>
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" id="copyUri" data-i18n="btn_copy_link"></button>
<button class="btn btn-secondary" id="copyAddr" data-i18n="btn_copy_addr"></button>
<button class="btn btn-secondary" id="downloadQr" data-i18n="btn_download_qr"></button>
</div>
<button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request"></button>
</div>
</main>
<footer>
<p data-i18n-html="footer"></p>
</footer>
<div class="lang-picker" id="langPicker">
<button class="lang-toggle" id="langToggle" aria-label="Language">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/>
</svg>
<span id="langCurrent">DE</span>
</button>
<div class="lang-dropdown" id="langDropdown"></div>
</div>
<div class="toast" id="toast"></div>
<script src="lib/qrcode.min.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>

1
lib/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

28
s.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
$code = trim($_SERVER['PATH_INFO'] ?? $_GET['c'] ?? '', '/');
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(404);
echo 'Not found';
exit;
}
$dbFile = __DIR__ . '/data/urls.json';
if (!file_exists($dbFile)) {
http_response_code(404);
echo 'Not found';
exit;
}
$urls = json_decode(file_get_contents($dbFile), true) ?: [];
if (!isset($urls[$code])) {
http_response_code(404);
echo 'Not found';
exit;
}
$hash = $urls[$code];
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
header('Location: ' . $base . '/#' . $hash, true, 302);
exit;

418
style.css Normal file
View File

@@ -0,0 +1,418 @@
:root {
--bg: #0d0d0d;
--bg-card: #1a1a1a;
--bg-input: #222;
--border: #333;
--text: #e0e0e0;
--text-muted: #888;
--accent: #ff6600;
--accent-hover: #ff8533;
--success: #4caf50;
--error: #f44336;
--radius: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
header {
text-align: center;
padding: 2rem 1rem 1rem;
}
.lang-picker {
position: fixed;
top: 0.75rem;
right: 0.75rem;
z-index: 50;
}
.lang-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.35rem 0.6rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
font-family: var(--font);
transition: border-color 0.2s, color 0.2s;
}
.lang-toggle:hover,
.lang-picker.open .lang-toggle {
border-color: var(--accent);
color: var(--text);
}
.lang-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-width: 120px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.lang-picker.open .lang-dropdown {
display: block;
}
.lang-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.lang-option:hover {
background: var(--bg-input);
color: var(--text);
}
.lang-option.active {
color: var(--accent);
}
header h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.5px;
}
header h1 a {
color: inherit;
text-decoration: none;
}
header h1 a:hover {
opacity: 0.8;
}
header h1 span {
color: var(--accent);
}
header p {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 0.3rem;
}
main {
width: 100%;
max-width: 480px;
padding: 1rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, select, textarea {
width: 100%;
padding: 0.7rem 0.8rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
color-scheme: dark;
}
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 2rem;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
input.invalid {
border-color: var(--error);
}
input.valid {
border-color: var(--success);
}
.field {
margin-bottom: 1rem;
}
.field:last-child {
margin-bottom: 0;
}
.amount-row {
display: flex;
gap: 0.5rem;
}
.amount-row input {
flex: 1;
}
.amount-row select {
width: 90px;
flex-shrink: 0;
}
.fiat-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.3rem;
min-height: 1.1em;
}
.fiat-hint.error {
color: var(--error);
}
textarea {
resize: vertical;
min-height: 60px;
}
.btn {
width: 100%;
padding: 0.8rem;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
font-size: 0.85rem;
padding: 0.6rem;
}
.btn-secondary:hover {
border-color: var(--accent);
}
#result {
display: none;
}
#result.visible {
display: block;
}
.qr-container {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.qr-container canvas,
.qr-container img {
border-radius: var(--radius);
padding: 12px;
background: #fff;
}
.uri-box {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
font-family: var(--mono);
font-size: 0.75rem;
word-break: break-all;
color: var(--text-muted);
margin-bottom: 0.8rem;
max-height: 80px;
overflow-y: auto;
}
.share-link-box {
margin-bottom: 0.8rem;
}
.share-link-box label {
margin-bottom: 0.3rem;
}
.share-link-row {
display: flex;
gap: 0.4rem;
}
.share-link-row input {
flex: 1;
font-size: 0.75rem;
padding: 0.5rem 0.6rem;
cursor: text;
}
.btn-icon {
width: auto;
padding: 0.5rem 0.6rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.actions {
display: flex;
gap: 0.5rem;
}
.actions .btn-secondary {
flex: 1;
}
.btn-new {
margin-top: 0.8rem;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
.btn-new:hover {
background: var(--accent);
color: #fff;
}
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--success);
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
opacity: 0;
transition: transform 0.3s, opacity 0.3s;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
footer {
text-align: center;
padding: 2rem 1rem;
color: var(--text-muted);
font-size: 0.75rem;
}
footer a {
color: var(--accent);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
.countdown {
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.8rem;
}
.countdown.expired {
color: var(--error);
}
.countdown.active {
color: var(--accent);
}
@media (max-width: 500px) {
main {
padding: 0.5rem;
}
.card {
padding: 1rem;
}
header h1 {
font-size: 1.5rem;
}
}

58
sw.js Normal file
View File

@@ -0,0 +1,58 @@
var CACHE_NAME = 'xmrpay-v1';
var ASSETS = [
'/',
'/index.html',
'/app.js',
'/i18n.js',
'/style.css',
'/lib/qrcode.min.js'
];
self.addEventListener('install', function (e) {
e.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(ASSETS);
})
);
self.skipWaiting();
});
self.addEventListener('activate', function (e) {
e.waitUntil(
caches.keys().then(function (names) {
return Promise.all(
names.filter(function (n) { return n !== CACHE_NAME; })
.map(function (n) { return caches.delete(n); })
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', function (e) {
var url = new URL(e.request.url);
// CoinGecko API — network only, don't cache
if (url.hostname !== location.hostname) {
e.respondWith(fetch(e.request));
return;
}
// App assets — cache first, fallback to network
e.respondWith(
caches.match(e.request).then(function (cached) {
var networkFetch = fetch(e.request).then(function (response) {
if (response.ok) {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(e.request, clone);
});
}
return response;
}).catch(function () {
return cached;
});
return cached || networkFetch;
})
);
});