Payment systems...

parent 946dfd79
...@@ -1375,7 +1375,7 @@ class DatabaseManager: ...@@ -1375,7 +1375,7 @@ class DatabaseManager:
''', (new_email, user_id)) ''', (new_email, user_id))
conn.commit() conn.commit()
def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None): def update_user_profile(self, user_id: int, username: str, email: str, display_name: str = None, profile_pic: str = None):
""" """
Update user profile (username and display_name, email is read-only). Update user profile (username and display_name, email is read-only).
...@@ -1384,23 +1384,25 @@ class DatabaseManager: ...@@ -1384,23 +1384,25 @@ class DatabaseManager:
username: New username username: New username
email: Email (ignored, kept for backward compatibility) email: Email (ignored, kept for backward compatibility)
display_name: New display name (optional) display_name: New display name (optional)
profile_pic: Base64-encoded profile picture data URL (optional)
""" """
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s' placeholder = '?' if self.db_type == 'sqlite' else '%s'
fields = ['username = ' + placeholder]
params = [username]
if display_name is not None: if display_name is not None:
cursor.execute(f''' fields.append('display_name = ' + placeholder)
UPDATE users params.append(display_name)
SET username = {placeholder}, display_name = {placeholder} if profile_pic is not None:
WHERE id = {placeholder} fields.append('profile_pic = ' + placeholder)
''', (username, display_name, user_id)) params.append(profile_pic)
else: params.append(user_id)
cursor.execute(f'''
UPDATE users cursor.execute(f'''
SET username = {placeholder} UPDATE users SET {', '.join(fields)} WHERE id = {placeholder}
WHERE id = {placeholder} ''', tuple(params))
''', (username, user_id))
conn.commit() conn.commit()
def sanitize_username(self, input_str: str) -> str: def sanitize_username(self, input_str: str) -> str:
...@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta ...@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'), ('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'), ('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'), ('reset_password_token', 'VARCHAR(255)', 'stripe_customer_id'),
('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token') ('reset_password_token_expires', 'TIMESTAMP NULL', 'reset_password_token'),
('profile_pic', 'TEXT', 'reset_password_token_expires')
] ]
for col_name, col_def, after_col in required_columns: for col_name, col_def, after_col in required_columns:
......
This diff is collapsed.
...@@ -55,25 +55,21 @@ class PaymentService: ...@@ -55,25 +55,21 @@ class PaymentService:
async def get_user_crypto_addresses(self, user_id: int) -> dict: async def get_user_crypto_addresses(self, user_id: int) -> dict:
"""Get or create crypto addresses for user""" """Get or create crypto addresses for user"""
addresses = {} addresses = {}
# Get enabled crypto types # Get enabled crypto gateways from payment gateway settings
with self.db._get_connection() as conn: gateways = self.db.get_payment_gateway_settings()
cursor = conn.cursor() crypto_types = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f""" for gateway_name, crypto_type in crypto_types.items():
SELECT crypto_type FROM crypto_consolidation_settings gw = gateways.get(gateway_name, {})
WHERE enabled = {placeholder} if not gw.get('enabled', False):
""", (True,)) continue
enabled_cryptos = cursor.fetchall() try:
address = await self.wallet_manager.get_or_create_user_address(user_id, crypto_type)
for crypto_config in enabled_cryptos: addresses[gateway_name] = address
crypto_type = crypto_config[0] except Exception as e:
address = await self.wallet_manager.get_or_create_user_address( logger.warning(f"Could not get/create {crypto_type} address for user {user_id}: {e}")
user_id,
crypto_type
)
addresses[crypto_type] = address
return addresses return addresses
async def get_user_wallet_balances(self, user_id: int) -> dict: async def get_user_wallet_balances(self, user_id: int) -> dict:
......
...@@ -59,6 +59,7 @@ from datetime import datetime, timedelta ...@@ -59,6 +59,7 @@ from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
import json import json
import re
import markdown import markdown
from urllib.parse import urljoin, urlencode from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
...@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request): ...@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request):
@app.post("/dashboard/profile") @app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form("")): async def dashboard_profile_save(request: Request, username: str = Form(...), display_name: str = Form(""), profile_pic: UploadFile = File(None)):
"""Save user profile changes (username and display_name)""" """Save user profile changes (username and display_name)"""
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse): if isinstance(auth_check, RedirectResponse):
...@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di ...@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
try: try:
db.update_user_profile(user_id, username, None, display_name if display_name else None) profile_pic_data = None
if profile_pic and profile_pic.filename:
content_type = profile_pic.content_type or ''
if not content_type.startswith('image/'):
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Invalid file type. Please upload an image."), status_code=303)
data = await profile_pic.read(1024 * 1024 + 1) # read up to 1MB+1 to detect oversized
if len(data) > 1024 * 1024:
return RedirectResponse(url=url_for(request, "/dashboard/profile?error=Image too large. Maximum size is 1MB."), status_code=303)
import base64
profile_pic_data = f"data:{content_type};base64,{base64.b64encode(data).decode()}"
db.update_user_profile(user_id, username, None, display_name if display_name else None, profile_pic_data)
# Update session with new username and display_name # Update session with new username and display_name
request.session['username'] = username request.session['username'] = username
request.session['display_name'] = display_name or '' request.session['display_name'] = display_name or ''
...@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request): ...@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request):
if not gw.get('enabled', False): if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400) return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: return deposit address (manual transfer) # Crypto: generate per-user HD wallet address
crypto_methods = {'bitcoin', 'ethereum', 'usdt', 'usdc'} crypto_methods = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
if method in crypto_methods: if method in crypto_methods:
address = gw.get('address', '') crypto_type = crypto_methods[method]
if not address: ps = getattr(request.app.state, 'payment_service', None)
return JSONResponse({"error": "Crypto address not configured"}, status_code=503) if ps is None:
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
try:
address = await ps.wallet_manager.get_or_create_user_address(user_id, crypto_type)
except Exception as e:
logger.error(f"Crypto address generation error: {e}")
return JSONResponse({"error": "Could not generate deposit address"}, status_code=503)
return JSONResponse({ return JSONResponse({
"type": "crypto", "type": "crypto",
"method": method, "method": method,
...@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request): ...@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request):
if method == 'paypal': if method == 'paypal':
try: try:
payment_service = getattr(request.app.state, 'payment_service', None) payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'paypal_handler'): if not payment_service or not hasattr(payment_service, 'paypal_handler'):
from decimal import Decimal return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
order = await payment_service.paypal_handler.create_order( from decimal import Decimal
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"} result = await payment_service.paypal_handler.create_topup_order(user_id, Decimal(str(amount)))
) if not result.get('success'):
return JSONResponse({"type": "paypal", "order_id": order.id}) logger.error(f"PayPal top-up error: {result.get('error')}")
# Fallback: direct PayPal redirect return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
client_id = gw.get('client_id', '') return JSONResponse({"type": "paypal", "approval_url": result['approval_url']})
sandbox = gw.get('sandbox', True)
paypal_base = "https://www.sandbox.paypal.com" if sandbox else "https://www.paypal.com"
return JSONResponse({
"type": "paypal",
"paypal_base": paypal_base,
"client_id": client_id,
"amount": amount,
})
except Exception as e: except Exception as e:
logger.error(f"PayPal top-up error: {e}") logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502) return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
...@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request): ...@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request):
# Convert markdown to HTML with extensions for better formatting # Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown( html_content = markdown.markdown(
markdown_content, markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'] extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists', 'toc']
) )
else: else:
html_content = "<p>Documentation file not found.</p>" html_content = "<p>Documentation file not found.</p>"
...@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request): ...@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request):
markdown_content, markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'] extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
) )
# Rewrite DOCUMENTATION.md links to /dashboard/docs
html_content = re.sub(
r'href="DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"',
html_content
)
html_content = re.sub(
r'href="DOCUMENTATION\.md"',
'href="/dashboard/docs"',
html_content
)
else: else:
html_content = "<p>README file not found.</p>" html_content = "<p>README file not found.</p>"
......
...@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="markdown-content"> <div class="markdown-content">
{{ content|safe }} {{ content|safe }}
</div> </div>
<script>
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView();
}
</script>
{% endblock %} {% endblock %}
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<div class="card"> <div class="card">
<h2>Account Information</h2> <h2>Account Information</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}"> <form method="POST" action="{{ url_for(request, '/dashboard/profile') }}" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required> <input type="text" id="username" name="username" value="{{ session.username }}" required>
...@@ -48,9 +48,22 @@ ...@@ -48,9 +48,22 @@
<div class="form-group"> <div class="form-group">
<label>Profile Picture</label> <label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1rem;"> <div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Current avatar" style="border-radius: 8px;"> <div style="position: relative; cursor: pointer;" onclick="document.getElementById('profile_pic').click()">
<p style="color: #a0a0a0;">Profile pictures are managed via <a href="https://gravatar.com" target="_blank">Gravatar</a> using your email address</p> {% if user.profile_pic %}
<img id="avatar-preview" src="{{ user.profile_pic }}" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% else %}
<img id="avatar-preview" src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Profile picture" style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
{% endif %}
<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s;" id="avatar-overlay">
<span style="color: #fff; font-size: 0.8rem; text-align: center;">Change</span>
</div>
</div>
<div>
<input type="file" id="profile_pic" name="profile_pic" accept="image/*" style="display: none;" onchange="previewAvatar(this)">
<button type="button" class="btn" style="background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;" onclick="document.getElementById('profile_pic').click()">Upload Image</button>
<small style="color: #a0a0a0; display: block; margin-top: 0.5rem;">Max 1 MB. JPG, PNG, GIF, WebP.</small>
</div>
</div> </div>
</div> </div>
...@@ -106,4 +119,19 @@ ...@@ -106,4 +119,19 @@
border-color: #667eea; border-color: #667eea;
} }
</style> </style>
<script>
function previewAvatar(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById('avatar-preview').src = e.target.result; };
reader.readAsDataURL(input.files[0]);
}
const avatarWrap = document.querySelector('[onclick="document.getElementById(\'profile_pic\').click()"]');
const overlay = document.getElementById('avatar-overlay');
if (avatarWrap && overlay) {
avatarWrap.addEventListener('mouseenter', () => overlay.style.opacity = '1');
avatarWrap.addEventListener('mouseleave', () => overlay.style.opacity = '0');
}
</script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<label style="font-weight: 500; margin-bottom: 10px; display: block;">Select Prompt File:</label> <label style="font-weight: 500; margin-bottom: 10px; display: block;">Select Prompt File:</label>
<select id="prompt-selector" onchange="switchPrompt()" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;"> <select id="prompt-selector" onchange="switchPrompt()">
{% for prompt in prompts %} {% for prompt in prompts %}
<option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option> <option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option>
{% endfor %} {% endfor %}
...@@ -42,9 +42,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -42,9 +42,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="hidden" name="prompt_key" id="prompt_key" value=""> <input type="hidden" name="prompt_key" id="prompt_key" value="">
<div class="form-group"> <div class="form-group">
<label for="prompt_content" style="font-weight: 500; margin-bottom: 10px; display: block;">Prompt Content:</label> <label for="prompt_content">Prompt Content:</label>
<textarea id="prompt_content" name="prompt_content" style="width: 100%; min-height: 400px; padding: 10px; border: 1px solid #ddd; border-radius: 3px; font-family: monospace; font-size: 13px; line-height: 1.5;"></textarea> <textarea id="prompt_content" name="prompt_content" style="min-height: 400px;"></textarea>
<small style="color: #666; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small> <small style="color: #a0a0a0; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
</div> </div>
<div style="display: flex; gap: 10px; margin-top: 20px;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
...@@ -52,7 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -52,7 +52,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if not is_admin %} {% if not is_admin %}
<button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button> <button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button>
{% endif %} {% endif %}
<a href="/dashboard" class="btn btn-secondary">Cancel</a> <a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
</div> </div>
</form> </form>
...@@ -96,33 +96,4 @@ if (prompts.length > 0) { ...@@ -96,33 +96,4 @@ if (prompts.length > 0) {
document.getElementById('prompt_key').value = prompts[0].key; document.getElementById('prompt_key').value = prompts[0].key;
} }
</script> </script>
<style>
.form-group {
margin-bottom: 20px;
}
textarea {
resize: vertical;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
{% endblock %} {% endblock %}
...@@ -94,8 +94,7 @@ ...@@ -94,8 +94,7 @@
<div style="display: flex; flex-wrap: wrap; gap: 8px;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %} {% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %} {% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %} <button onclick="openCryptoModal('{{ name }}')"
<button onclick="openCryptoModal('{{ name }}','{{ address }}')"
style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;" style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;"
onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'"> onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i> {% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
...@@ -243,9 +242,10 @@ ...@@ -243,9 +242,10 @@
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// ── Amount buttons ────────────────────────────────────────── // ── Amount buttons ──────────────────────────────────────────
let selectedAmount = null; let selectedAmount = 15;
document.querySelectorAll('.amount-btn').forEach(btn => { document.querySelectorAll('.amount-btn').forEach(btn => {
if (parseFloat(btn.dataset.amount) === selectedAmount) btn.classList.add('active');
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active'); this.classList.add('active');
...@@ -423,25 +423,50 @@ function copyAddress(addr, btn) { ...@@ -423,25 +423,50 @@ function copyAddress(addr, btn) {
let _cryptoAddr = ''; let _cryptoAddr = '';
function openCryptoModal(name, address) { function openCryptoModal(name) {
const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' }; const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' };
document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase(); document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase();
document.getElementById('cryptoAddress').textContent = address || 'Address not configured'; document.getElementById('cryptoAddress').textContent = 'Loading…';
_cryptoAddr = address; _cryptoAddr = '';
const qrEl = document.getElementById('cryptoQR'); const qrEl = document.getElementById('cryptoQR');
qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>'; qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>';
document.getElementById('cryptoModal').classList.add('active'); document.getElementById('cryptoModal').classList.add('active');
if (address) { // Fetch a per-user deposit address from the server
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`; const amount = getAmount() || 15;
const img = new Image(); fetch('/dashboard/wallet/topup', {
img.onload = () => { qrEl.innerHTML = ''; qrEl.appendChild(img); }; method: 'POST',
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; }; headers: { 'Content-Type': 'application/json' },
img.src = qrUrl; body: JSON.stringify({ amount, payment_method: name })
img.style.cssText = 'width:200px;height:200px;border-radius:8px;'; })
} .then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById('cryptoAddress').textContent = data.error;
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
return;
}
const address = data.address || '';
_cryptoAddr = address;
document.getElementById('cryptoAddress').textContent = address || 'Address unavailable';
if (address) {
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`;
const img = new Image();
img.onload = () => { qrEl.innerHTML = ''; qrEl.appendChild(img); };
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; };
img.src = qrUrl;
img.style.cssText = 'width:200px;height:200px;border-radius:8px;';
} else {
qrEl.innerHTML = '<span style="color:#888;font-size:12px;">No address</span>';
}
})
.catch(() => {
document.getElementById('cryptoAddress').textContent = 'Failed to load address';
qrEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Error</span>';
});
} }
function closeCryptoModal() { function closeCryptoModal() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment