Payment systems...

parent 946dfd79
......@@ -1375,7 +1375,7 @@ class DatabaseManager:
''', (new_email, user_id))
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).
......@@ -1384,23 +1384,25 @@ class DatabaseManager:
username: New username
email: Email (ignored, kept for backward compatibility)
display_name: New display name (optional)
profile_pic: Base64-encoded profile picture data URL (optional)
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
fields = ['username = ' + placeholder]
params = [username]
if display_name is not None:
cursor.execute(f'''
UPDATE users
SET username = {placeholder}, display_name = {placeholder}
WHERE id = {placeholder}
''', (username, display_name, user_id))
else:
cursor.execute(f'''
UPDATE users
SET username = {placeholder}
WHERE id = {placeholder}
''', (username, user_id))
fields.append('display_name = ' + placeholder)
params.append(display_name)
if profile_pic is not None:
fields.append('profile_pic = ' + placeholder)
params.append(profile_pic)
params.append(user_id)
cursor.execute(f'''
UPDATE users SET {', '.join(fields)} WHERE id = {placeholder}
''', tuple(params))
conn.commit()
def sanitize_username(self, input_str: str) -> str:
......@@ -4315,7 +4317,8 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
('subscription_expires', 'TIMESTAMP NULL', 'tier_id'),
('stripe_customer_id', 'VARCHAR(100)', 'subscription_expires'),
('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:
......
......@@ -115,82 +115,166 @@ class BlockchainMonitor:
logger.error(f"Error checking Bitcoin address {address}: {e}")
async def _get_bitcoin_transactions(self, address: str) -> List[Dict]:
"""
Fetch Bitcoin transactions from Blockchain.com API.
Args:
address: Bitcoin address to check
Returns:
List of transaction dicts with keys: hash, from_address, amount, confirmations
"""
url = f"https://blockchain.info/rawaddr/{address}"
"""Fetch Bitcoin transactions from Blockchain.com API."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
addr_resp = await client.get(f"https://blockchain.info/rawaddr/{address}?limit=25")
addr_resp.raise_for_status()
data = addr_resp.json()
# Fetch latest block height for confirmation calculation
try:
block_resp = await client.get("https://blockchain.info/latestblock")
block_resp.raise_for_status()
data['latest_block'] = block_resp.json()
except Exception:
pass # confirmations will be 0 if unavailable
return self._parse_blockchain_com_btc(data, address)
def _parse_blockchain_com_btc(self, data: Dict, target_address: str) -> List[Dict]:
"""
Parse Blockchain.com API response for Bitcoin transactions.
Args:
data: API response data
target_address: The address we're monitoring (to identify incoming txs)
Returns:
List of incoming transaction dicts
"""
"""Parse Blockchain.com API response for Bitcoin transactions."""
transactions = []
latest_block = data.get('latest_block', {}).get('height', 0)
for tx in data.get('txs', []):
# Check if this transaction sends to our address
for output in tx.get('out', []):
if output.get('addr') == target_address:
# This is an incoming transaction
amount_satoshi = output.get('value', 0)
amount_btc = amount_satoshi / 100000000 # Convert satoshi to BTC
# Get sender address (first input address)
amount_btc = amount_satoshi / 100_000_000
from_address = None
if tx.get('inputs'):
prev_out = tx['inputs'][0].get('prev_out', {})
from_address = prev_out.get('addr', 'unknown')
tx_block = tx.get('block_height')
confirmations = (latest_block - tx_block + 1) if tx_block else 0
transactions.append({
'hash': tx['hash'],
'from_address': from_address or 'unknown',
'amount': amount_btc,
'confirmations': data.get('n_tx', 0) # Use block height as proxy
'confirmations': confirmations,
})
return transactions
async def check_ethereum_addresses(self):
"""
Check all Ethereum addresses for transactions.
Placeholder - would use Etherscan/Infura API in production.
Check all Ethereum addresses (and ERC-20 tokens) for transactions.
Uses Etherscan public API (no key required for basic polling).
"""
# Get all Ethereum addresses
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, user_id, address
SELECT id, user_id, address, crypto_type
FROM user_crypto_addresses
WHERE crypto_type = 'eth'
WHERE crypto_type IN ('eth', 'usdt', 'usdc')
""")
addresses = cursor.fetchall()
if not addresses:
logger.debug("No Ethereum addresses to check")
return
logger.info(f"Checking {len(addresses)} Ethereum addresses (placeholder)...")
# TODO: Implement Ethereum checking with Etherscan/Infura API
logger.info(f"Checking {len(addresses)} Ethereum addresses...")
# ERC-20 contract addresses (mainnet)
ERC20_CONTRACTS = {
'usdt': '0xdac17f958d2ee523a2206206994597c13d831ec7',
'usdc': '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
}
for address_id, user_id, address, crypto_type in addresses:
try:
if crypto_type == 'eth':
transactions = await self._get_ethereum_transactions(address)
else:
contract = ERC20_CONTRACTS.get(crypto_type)
if not contract:
continue
transactions = await self._get_erc20_transactions(address, contract, crypto_type)
for tx in transactions:
await self.process_transaction(
user_id=user_id,
crypto_type=crypto_type,
tx_hash=tx['hash'],
from_address=tx['from_address'],
to_address=address,
amount=tx['amount'],
confirmations=tx['confirmations']
)
except Exception as e:
logger.error(f"Error checking Ethereum address {address}: {e}")
async def _get_ethereum_transactions(self, address: str) -> List[Dict]:
"""Fetch ETH transactions from Etherscan public API."""
url = (
f"https://api.etherscan.io/api"
f"?module=account&action=txlist&address={address}"
f"&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
if data.get('status') != '1':
return []
results = []
for tx in data.get('result', []):
# Only incoming transactions
if tx.get('to', '').lower() != address.lower():
continue
amount_wei = int(tx.get('value', 0))
amount_eth = amount_wei / 1e18
if amount_eth <= 0:
continue
confirmations = int(tx.get('confirmations', 0))
results.append({
'hash': tx['hash'],
'from_address': tx.get('from', 'unknown'),
'amount': amount_eth,
'confirmations': confirmations,
})
return results
async def _get_erc20_transactions(self, address: str, contract: str, crypto_type: str) -> List[Dict]:
"""Fetch ERC-20 token transfers from Etherscan public API."""
url = (
f"https://api.etherscan.io/api"
f"?module=account&action=tokentx&contractaddress={contract}"
f"&address={address}&startblock=0&endblock=99999999&sort=desc&offset=25&page=1"
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
if data.get('status') != '1':
return []
decimals_map = {'usdt': 6, 'usdc': 6}
decimals = decimals_map.get(crypto_type, 18)
results = []
for tx in data.get('result', []):
if tx.get('to', '').lower() != address.lower():
continue
amount_raw = int(tx.get('value', 0))
amount = amount_raw / (10 ** decimals)
if amount <= 0:
continue
confirmations = int(tx.get('confirmations', 0))
results.append({
'hash': tx['hash'],
'from_address': tx.get('from', 'unknown'),
'amount': amount,
'confirmations': confirmations,
})
return results
async def process_transaction(
self,
......@@ -219,30 +303,29 @@ class BlockchainMonitor:
"""
crypto_type = crypto_type.lower()
required_confs = self.required_confirmations.get(crypto_type, 3)
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Check if transaction already exists
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = ?",
f"SELECT id, status, confirmations FROM crypto_transactions WHERE tx_hash = {placeholder}",
(tx_hash,)
)
existing = cursor.fetchone()
if existing:
# Update existing transaction
tx_id, status, old_confirmations = existing
# Only update if confirmations increased or status changed
if confirmations > old_confirmations or status == 'pending':
new_status = 'confirmed' if confirmations >= required_confs else 'pending'
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
UPDATE crypto_transactions
SET confirmations = ?, status = ?, confirmed_at = ?
WHERE id = ?
SET confirmations = {placeholder}, status = {placeholder}, confirmed_at = {placeholder}
WHERE id = {placeholder}
""", (
confirmations,
new_status,
......@@ -250,125 +333,133 @@ class BlockchainMonitor:
tx_id
))
conn.commit()
logger.info(f"Updated transaction {tx_hash}: {confirmations} confirmations, status={new_status}")
# Credit wallet if newly confirmed
if new_status == 'confirmed' and status == 'pending':
await self.credit_user_wallet(user_id, crypto_type, amount, tx_id)
else:
# Create new transaction
status = 'confirmed' if confirmations >= required_confs else 'pending'
# Get address_id
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM user_crypto_addresses WHERE user_id = ? AND address = ?",
f"SELECT id FROM user_crypto_addresses WHERE user_id = {placeholder} AND address = {placeholder}",
(user_id, to_address)
)
address_row = cursor.fetchone()
address_id = address_row[0] if address_row else None
if not address_id:
logger.error(f"Address {to_address} not found for user {user_id}")
return
# Convert to fiat
try:
amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount)
except Exception as e:
logger.warning(f"Could not convert to fiat: {e}")
amount_fiat = None
# Insert transaction
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
cursor.execute(f"""
INSERT INTO crypto_transactions
(user_id, address_id, crypto_type, tx_hash, amount_crypto, amount_fiat,
confirmations, required_confirmations, status, detected_at, confirmed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ({placeholder},{placeholder},{placeholder},{placeholder},{placeholder},
{placeholder},{placeholder},{placeholder},{placeholder},{placeholder},{placeholder})
""", (
user_id,
address_id,
crypto_type,
tx_hash,
amount,
amount_fiat,
confirmations,
required_confs,
status,
user_id, address_id, crypto_type, tx_hash, amount, amount_fiat,
confirmations, required_confs, status,
datetime.utcnow(),
datetime.utcnow() if status == 'confirmed' else None
))
tx_id = cursor.lastrowid
conn.commit()
logger.info(f"Created transaction {tx_hash}: {amount} {crypto_type}, status={status}")
# Credit wallet if confirmed
if status == 'confirmed':
await self.credit_user_wallet(user_id, crypto_type, amount, tx_id)
async def credit_user_wallet(self, user_id: int, crypto_type: str, amount: float, tx_id: int):
"""
Credit user's crypto wallet with confirmed transaction amount.
Args:
user_id: User ID
crypto_type: Cryptocurrency type
amount: Amount in crypto
tx_id: Transaction ID
Credit user's fiat wallet with the confirmed transaction amount.
"""
# Convert to fiat
try:
amount_fiat = await self.price_service.convert_crypto_to_fiat(crypto_type, amount)
except Exception as e:
logger.error(f"Could not convert to fiat for crediting: {e}")
amount_fiat = 0
now = datetime.utcnow()
# Update crypto wallet balance
with self.db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f"""
UPDATE user_crypto_wallets
SET balance_crypto = balance_crypto + ?,
balance_fiat = balance_fiat + ?,
last_updated = ?
WHERE user_id = ? AND crypto_type = ?
""", (amount, amount_fiat, datetime.utcnow(), user_id, crypto_type))
# Mark transaction as credited
cursor.execute("""
UPDATE crypto_transactions
SET credited_at = ?
WHERE id = ?
""", (datetime.utcnow(), tx_id))
SET balance_crypto = balance_crypto + {placeholder},
balance_fiat = balance_fiat + {placeholder},
last_updated = {placeholder}
WHERE user_id = {placeholder} AND crypto_type = {placeholder}
""", (amount, amount_fiat, now, user_id, crypto_type))
cursor.execute(f"""
UPDATE crypto_transactions SET credited_at = {placeholder} WHERE id = {placeholder}
""", (now, tx_id))
conn.commit()
# Also credit fiat wallet
# Credit fiat wallet directly via DB
if amount_fiat > 0:
from aisbf.payments.wallet.manager import WalletManager
from sqlalchemy.ext.asyncio import AsyncSession
try:
async with AsyncSession(self.db.engine) as session:
wallet_manager = WalletManager(session)
await wallet_manager.credit_wallet(
user_id=user_id,
amount=Decimal(str(amount_fiat)),
transaction_details={
'payment_gateway': f'crypto_{crypto_type}',
'gateway_transaction_id': f'crypto_tx_{tx_id}',
'description': f'Wallet top up via {crypto_type.upper()} payment',
'metadata': {'crypto_amount': amount, 'crypto_type': crypto_type, 'tx_id': tx_id}
}
)
await session.commit()
logger.info(f"Fiat wallet credited {amount_fiat:.2f} USD for user {user_id} from crypto payment")
with self.db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Ensure wallet row exists
if self.db.db_type == 'sqlite':
cursor.execute(f"""
INSERT OR IGNORE INTO user_wallets (user_id, balance, currency_code)
VALUES ({placeholder}, 0.00, 'USD')
""", (user_id,))
else:
cursor.execute(f"""
INSERT IGNORE INTO user_wallets (user_id, balance, currency_code)
VALUES ({placeholder}, 0.00, 'USD')
""", (user_id,))
cursor.execute(f"""
UPDATE user_wallets
SET balance = balance + {placeholder}, updated_at = {placeholder}
WHERE user_id = {placeholder}
""", (amount_fiat, now, user_id))
# Get wallet id
cursor.execute(f"SELECT id FROM user_wallets WHERE user_id = {placeholder}", (user_id,))
wallet_row = cursor.fetchone()
wallet_id = wallet_row[0] if wallet_row else None
if wallet_id:
import json
cursor.execute(f"""
INSERT INTO wallet_transactions
(user_id, wallet_id, amount, type, status,
payment_gateway, gateway_transaction_id, description, metadata)
VALUES ({placeholder},{placeholder},{placeholder},'credit','completed',
{placeholder},{placeholder},{placeholder},{placeholder})
""", (
user_id, wallet_id, amount_fiat,
f'crypto_{crypto_type}',
f'crypto_tx_{tx_id}',
f'Wallet top-up via {crypto_type.upper()} payment',
json.dumps({'crypto_amount': amount, 'crypto_type': crypto_type, 'tx_id': tx_id})
))
conn.commit()
logger.info(f"Fiat wallet credited {amount_fiat:.2f} for user {user_id} from {crypto_type} payment")
except Exception as e:
logger.error(f"Error crediting fiat wallet from crypto payment: {e}")
......
......@@ -55,25 +55,21 @@ class PaymentService:
async def get_user_crypto_addresses(self, user_id: int) -> dict:
"""Get or create crypto addresses for user"""
addresses = {}
# Get enabled crypto types
with self.db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
cursor.execute(f"""
SELECT crypto_type FROM crypto_consolidation_settings
WHERE enabled = {placeholder}
""", (True,))
enabled_cryptos = cursor.fetchall()
for crypto_config in enabled_cryptos:
crypto_type = crypto_config[0]
address = await self.wallet_manager.get_or_create_user_address(
user_id,
crypto_type
)
addresses[crypto_type] = address
# Get enabled crypto gateways from payment gateway settings
gateways = self.db.get_payment_gateway_settings()
crypto_types = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
for gateway_name, crypto_type in crypto_types.items():
gw = gateways.get(gateway_name, {})
if not gw.get('enabled', False):
continue
try:
address = await self.wallet_manager.get_or_create_user_address(user_id, crypto_type)
addresses[gateway_name] = address
except Exception as e:
logger.warning(f"Could not get/create {crypto_type} address for user {user_id}: {e}")
return addresses
async def get_user_wallet_balances(self, user_id: int) -> dict:
......
......@@ -59,6 +59,7 @@ from datetime import datetime, timedelta
from collections import defaultdict
from pathlib import Path
import json
import re
import markdown
from urllib.parse import urljoin, urlencode
from cryptography.fernet import Fernet
......@@ -3198,7 +3199,7 @@ async def dashboard_profile(request: Request):
@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)"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
......@@ -3208,7 +3209,18 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
db = DatabaseRegistry.get_config_database()
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
request.session['username'] = username
request.session['display_name'] = display_name or ''
......@@ -8205,12 +8217,18 @@ async def dashboard_wallet_topup(request: Request):
if not gw.get('enabled', False):
return JSONResponse({"error": f"Payment method '{method}' is not enabled"}, status_code=400)
# Crypto: return deposit address (manual transfer)
crypto_methods = {'bitcoin', 'ethereum', 'usdt', 'usdc'}
# Crypto: generate per-user HD wallet address
crypto_methods = {'bitcoin': 'btc', 'ethereum': 'eth', 'usdt': 'usdt', 'usdc': 'usdc'}
if method in crypto_methods:
address = gw.get('address', '')
if not address:
return JSONResponse({"error": "Crypto address not configured"}, status_code=503)
crypto_type = crypto_methods[method]
ps = getattr(request.app.state, 'payment_service', None)
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({
"type": "crypto",
"method": method,
......@@ -8257,22 +8275,14 @@ async def dashboard_wallet_topup(request: Request):
if method == 'paypal':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'paypal_handler'):
from decimal import Decimal
order = await payment_service.paypal_handler.create_order(
user_id, Decimal(str(amount)), metadata={"type": "wallet_topup"}
)
return JSONResponse({"type": "paypal", "order_id": order.id})
# Fallback: direct PayPal redirect
client_id = gw.get('client_id', '')
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,
})
if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
from decimal import Decimal
result = await payment_service.paypal_handler.create_topup_order(user_id, Decimal(str(amount)))
if not result.get('success'):
logger.error(f"PayPal top-up error: {result.get('error')}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
return JSONResponse({"type": "paypal", "approval_url": result['approval_url']})
except Exception as e:
logger.error(f"PayPal top-up error: {e}")
return JSONResponse({"error": "PayPal checkout failed. Please try again."}, status_code=502)
......@@ -8994,7 +9004,7 @@ async def dashboard_docs(request: Request):
# Convert markdown to HTML with extensions for better formatting
html_content = markdown.markdown(
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists', 'toc']
)
else:
html_content = "<p>Documentation file not found.</p>"
......@@ -9039,6 +9049,17 @@ async def dashboard_about(request: Request):
markdown_content,
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:
html_content = "<p>README file not found.</p>"
......
......@@ -135,4 +135,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="markdown-content">
{{ content|safe }}
</div>
<script>
if (location.hash) {
const el = document.querySelector(location.hash);
if (el) el.scrollIntoView();
}
</script>
{% endblock %}
......@@ -18,7 +18,7 @@
<div class="card">
<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">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required>
......@@ -48,9 +48,22 @@
<div class="form-group">
<label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1rem;">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Current avatar" style="border-radius: 8px;">
<p style="color: #a0a0a0;">Profile pictures are managed via <a href="https://gravatar.com" target="_blank">Gravatar</a> using your email address</p>
<div style="display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<div style="position: relative; cursor: pointer;" onclick="document.getElementById('profile_pic').click()">
{% 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>
......@@ -106,4 +119,19 @@
border-color: #667eea;
}
</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 %}
\ No newline at end of file
......@@ -31,7 +31,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div style="margin-bottom: 20px;">
<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 %}
<option value="{{ prompt.key }}" {% if loop.first %}selected{% endif %}>{{ prompt.name }}</option>
{% endfor %}
......@@ -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="">
<div class="form-group">
<label for="prompt_content" style="font-weight: 500; margin-bottom: 10px; display: block;">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>
<small style="color: #666; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
<label for="prompt_content">Prompt Content:</label>
<textarea id="prompt_content" name="prompt_content" style="min-height: 400px;"></textarea>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Edit the prompt template. Use markdown formatting as needed.</small>
</div>
<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/>.
{% if not is_admin %}
<button type="button" class="btn btn-secondary" onclick="resetPrompt()">Reset to Default</button>
{% endif %}
<a href="/dashboard" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
......@@ -96,33 +96,4 @@ if (prompts.length > 0) {
document.getElementById('prompt_key').value = prompts[0].key;
}
</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 %}
......@@ -94,8 +94,7 @@
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %}
<button onclick="openCryptoModal('{{ name }}','{{ address }}')"
<button onclick="openCryptoModal('{{ name }}')"
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'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
......@@ -243,9 +242,10 @@
document.addEventListener('DOMContentLoaded', function () {
// ── Amount buttons ──────────────────────────────────────────
let selectedAmount = null;
let selectedAmount = 15;
document.querySelectorAll('.amount-btn').forEach(btn => {
if (parseFloat(btn.dataset.amount) === selectedAmount) btn.classList.add('active');
btn.addEventListener('click', function () {
document.querySelectorAll('.amount-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
......@@ -423,25 +423,50 @@ function copyAddress(addr, btn) {
let _cryptoAddr = '';
function openCryptoModal(name, address) {
function openCryptoModal(name) {
const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' };
document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase();
document.getElementById('cryptoAddress').textContent = address || 'Address not configured';
_cryptoAddr = address;
document.getElementById('cryptoAddress').textContent = 'Loading…';
_cryptoAddr = '';
const qrEl = document.getElementById('cryptoQR');
qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>';
document.getElementById('cryptoModal').classList.add('active');
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;';
}
// Fetch a per-user deposit address from the server
const amount = getAmount() || 15;
fetch('/dashboard/wallet/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, payment_method: name })
})
.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() {
......
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