Improve crypto payment UX and fix layout issues

- Remove all flex layouts and replace with CSS Grid for consistent styling
- Move pending crypto quotes section to bottom of confirmation page
- Update button text to use "confirm checkout" terminology
- Add crypto payment cancellation with redirect to active cart
- Remove cancel buttons from confirmation page to reduce clutter
- Implement smart redirect URLs for completed crypto payments
This commit is contained in:
Russell Ballestrini 2025-09-23 16:41:04 -04:00
parent 4be33ee528
commit 54da2d726d
7 changed files with 208 additions and 42 deletions

View file

@ -29,6 +29,7 @@ class CryptoPayment(RBase, Base):
STATUS_EXPIRED = "expired"
STATUS_EXPIRED_REFUNDED = "expired_refunded"
STATUS_UNDERPAID_REFUNDED = "underpaid_refunded"
STATUS_CANCELLED = "cancelled"
# Active statuses that should be processed by the watcher
ACTIVE_STATUSES = [
@ -43,6 +44,7 @@ class CryptoPayment(RBase, Base):
STATUS_EXPIRED,
STATUS_EXPIRED_REFUNDED,
STATUS_UNDERPAID_REFUNDED,
STATUS_CANCELLED,
]
id = Column(UUIDType, primary_key=True, index=True)

View file

@ -153,3 +153,4 @@ def includeme(config):
config.add_route("crypto_doge_start", "/crypto/doge/start")
config.add_route("crypto_doge_status", "/crypto/doge/status/{payment_id}")
config.add_route("crypto_quote", "/crypto/quote/{payment_id}")
config.add_route("crypto_cancel", "/crypto/cancel/{payment_id}")

View file

@ -43,8 +43,8 @@
<br/>
{% if stripe_enabled %}
Are you sure you want to charge <br/>
<b>${{ '{:,.2f}'.format(cart.total) }}</b> to your active credit card?
Are you sure you want to confirm checkout & pay <br/>
<b>${{ '{:,.2f}'.format(cart.total) }}</b>?
{% else %}
{% if cart.requires_payment %}
Choose a payment method below.
@ -59,47 +59,17 @@
{% if stripe_enabled %}
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
{% include "snippets/csrf.j2" %}
<button type="submit" class="cart-checkout-button mps-button">Yes, Complete Checkout</button>
<button type="submit" class="cart-checkout-button mps-button">Yes, Confirm Checkout with Active Credit Card</button>
</form>
{% endif %}
{# Show pending crypto quotes if user has any #}
{% if pending_crypto_quotes %}
<div style="background:#fff3cd; border:1px solid #ffc107; border-radius:4px; padding:15px; margin:20px 0;">
<h3 style="margin-top:0;">📋 Your Pending Crypto Quotes</h3>
<p style="margin-bottom:15px;">You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:</p>
{% for quote in pending_crypto_quotes %}
<div style="background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; padding:10px; margin:8px 0;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<strong>{{ quote.coin_type }} Payment</strong>
<span style="color:#666; margin-left:10px;">
Status: {{ quote.status.title() }}
{% if quote.current_confirmations > 0 %}
({{ quote.confirmation_status }})
{% endif %}
</span>
</div>
<a href="{{ request.route_url('crypto_quote', payment_id=quote.id) }}" class="mps-button" style="background:#17a2b8; font-size:12px; padding:5px 10px;">View Quote</a>
</div>
<div style="font-size:12px; color:#666; margin-top:5px;">
Amount: {{ '%.8f' % (quote.expected_amount / (1000000000000 if quote.coin_type == 'XMR' else 100000000)) }} {{ quote.coin_type }}
</div>
</div>
{% endfor %}
<p style="margin-bottom:0; font-size:14px; color:#856404;">
💡 Complete an existing payment or create a new quote below.
</p>
</div>
{% endif %}
{# Offer Monero if globally enabled, shop has enabled processor, cart requires payment and is single-shop #}
{% if monero_enabled and xmr_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
<br/>
<form method="POST" action="{{ request.route_url('crypto_xmr_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#4c4;">Pay with Monero (XMR)</button>
<button type="submit" class="mps-button" style="background:#4c4;">Yes, Confirm Checkout with Monero (XMR)</button>
</form>
{% if not request.has_xmr_refund_address %}
<a href="/u/settings/crypto" target="_blank" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
@ -114,7 +84,7 @@
<form method="POST" action="{{ request.route_url('crypto_doge_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#d4a574;">Pay with Dogecoin (DOGE) 🐕</button>
<button type="submit" class="mps-button" style="background:#d4a574;">Yes, Confirm Checkout with Dogecoin (DOGE) 🐕</button>
</form>
{% if not request.has_doge_refund_address %}
<a href="/u/settings/crypto" target="_blank" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
@ -149,8 +119,67 @@
<p>No payment methods are enabled. Please contact the shop owner.</p>
{% endif %}
{# Show pending crypto quotes if user has any #}
{% if pending_crypto_quotes %}
<div style="background:#fff3cd; border:1px solid #ffc107; border-radius:4px; padding:15px; margin:20px 0;">
<h3 style="margin-top:0;">📋 Your Pending Crypto Quotes</h3>
<p style="margin-bottom:15px;">You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:</p>
{% for quote in pending_crypto_quotes %}
<div style="background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; padding:10px; margin:8px 0;">
<div style="display:grid; grid-template-columns: 1fr auto; align-items:start;">
<div>
<strong>{{ quote.coin_type }} Payment</strong>
<span style="color:#666; margin-left:10px;">
Status: {{ quote.status.title() }}
{% if quote.current_confirmations > 0 %}
({{ quote.confirmation_status }})
{% endif %}
</span>
</div>
<div>
<a href="{{ request.route_url('crypto_quote', payment_id=quote.id) }}" class="mps-button" style="background:#17a2b8; font-size:12px; padding:5px 10px;">View Quote</a>
</div>
</div>
<div style="font-size:12px; color:#666; margin-top:5px;">
Amount: {{ '%.8f' % (quote.expected_amount / (1000000000000 if quote.coin_type == 'XMR' else 100000000)) }} {{ quote.coin_type }}
</div>
</div>
{% endfor %}
<p style="margin-bottom:0; font-size:14px; color:#856404;">
💡 Complete an existing payment or create a new quote below.
</p>
</div>
{% endif %}
</section>
</section>
<script>
function cancelQuote(paymentId) {
if (!confirm('Are you sure you want to cancel this crypto payment quote?')) {
return;
}
fetch(`/crypto/cancel/${paymentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Payment quote cancelled successfully');
window.location.reload();
} else {
alert('Failed to cancel quote: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
alert('Error cancelling quote: ' + error.message);
});
}
</script>
{%- endblock -%}

View file

@ -15,9 +15,12 @@
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 8px 0;">
<button class="mps-button" id="copy-amt" style="background:#4c4;">Copy Amount</button>
<button class="mps-button" id="copy-addr" style="background:#4c4;">Copy Address</button>
<a class="mps-button" href="{{ wallet_uri_scheme }}:{{ address }}?{% if coin_symbol == 'XMR' %}tx_amount{% else %}amount{% endif %}={{ amount_fmt }}" target="_blank" rel="noopener" style="background:#4c4;">Open in Wallet</a>
<button class="mps-button" id="copy-amt" style="background:#4c4; grid-row: 1;">Copy Amount</button>
<button class="mps-button" id="copy-addr" style="background:#4c4; grid-row: 1;">Copy Address</button>
<a class="mps-button" href="{{ wallet_uri_scheme }}:{{ address }}?{% if coin_symbol == 'XMR' %}tx_amount{% else %}amount{% endif %}={{ amount_fmt }}" target="_blank" rel="noopener" style="background:#4c4; grid-row: 1;">Open in Wallet</a>
{% if status == 'pending' %}
<button id="cancel-btn" onclick="cancelQuote('{{ payment_id }}')" class="mps-button" style="background:#dc3545; border:none; cursor:pointer; grid-column: 1 / -1; grid-row: 2;">Cancel Quote</button>
{% endif %}
</div>
<p>Status: <span id="status">{{ status }}</span></p>
@ -211,8 +214,9 @@
}
// Optional: redirect when confirmed
if (data.status === 'confirmed') {
// Redirect to purchases page (matches Stripe flow)
window.location.href = '/u/purchases';
// Use smart redirect URL if provided, otherwise fallback to purchases page
const redirectUrl = data.redirect_url || '/u/purchases';
window.location.href = redirectUrl;
}
} catch(e) {
// ignore transient errors
@ -221,6 +225,42 @@
poll();
setInterval(poll, 5000);
})();
// Cancel quote function
function cancelQuote(paymentId) {
if (!confirm('Are you sure you want to cancel this crypto payment quote?')) {
return;
}
fetch(`/crypto/cancel/${paymentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]') ? document.querySelector('input[name="csrf_token"]').value : ''
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Payment quote cancelled successfully');
// Redirect to cart if cart_url is provided
if (data.cart_url) {
window.location.href = data.cart_url;
} else {
// Fallback: Update status on page and hide cancel button
const statusEl = document.getElementById('status');
const cancelBtn = document.getElementById('cancel-btn');
if (statusEl) statusEl.textContent = 'cancelled';
if (cancelBtn) cancelBtn.style.display = 'none';
}
} else {
alert('Failed to cancel quote: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
alert('Error cancelling quote: ' + error.message);
});
}
</script>
{% else %}
<p>Monero checkout is initializing. If this message persists, the RPC may not be configured.</p>

View file

@ -94,7 +94,8 @@
<style>
.radio-group {
display: flex;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
}

View file

@ -39,6 +39,32 @@ def carts(request):
return {}
def get_smart_purchase_redirect_url(invoices):
"""
Determine the best redirect URL after purchase completion.
Returns:
- Product download URL if single digital product
- Invoice URL if multiple products or physical products
- General purchases page as fallback
"""
# Handle single invoice with single digital product
if len(invoices) == 1:
invoice = invoices[0]
line_items = invoice.line_items
# Single digital product - redirect to download page
if len(line_items) == 1 and line_items[0].product.has_product_file:
product = line_items[0].product
return f"/p/{product.id}/{product.slug}"
# Multiple products or physical products - redirect to invoice
return f"/u/invoice/{invoice.id}"
# Multiple invoices - redirect to general purchases page
return "/u/purchases"
def save_cart(request):
if request.active_cart.is_empty == False:
new_cart = request.shop.create_new_cart_for_user(request.user)
@ -663,7 +689,8 @@ def cart_complete_checkout(request):
)
save_cart(request)
return HTTPFound("/u/purchases")
redirect_url = get_smart_purchase_redirect_url(invoices)
return HTTPFound(redirect_url)
except stripe.error.CardError as e:
request.tm.abort()

View file

@ -638,6 +638,13 @@ def crypto_xmr_status(request):
"current_confirmations": crypto_payment.current_confirmations or 0,
"expires_at": crypto_payment.quote_expires_at,
}
# Add smart redirect URL when payment is confirmed
if crypto_payment.status == "confirmed" and crypto_payment.invoice:
from ..views.cart import get_smart_purchase_redirect_url
redirect_url = get_smart_purchase_redirect_url([crypto_payment.invoice])
payload["redirect_url"] = redirect_url
return Response(
json.dumps(payload), content_type="application/json", charset="utf-8"
)
@ -675,6 +682,65 @@ def crypto_doge_status(request):
"received_koinu": crypto_payment.received_amount,
"expected_koinu": crypto_payment.expected_amount,
}
# Add smart redirect URL when payment is confirmed
if crypto_payment.status == "confirmed" and crypto_payment.invoice:
from ..views.cart import get_smart_purchase_redirect_url
redirect_url = get_smart_purchase_redirect_url([crypto_payment.invoice])
payload["redirect_url"] = redirect_url
return Response(
json.dumps(payload), content_type="application/json", charset="utf-8"
)
@view_config(route_name="crypto_cancel", request_method="POST")
@user_required()
def crypto_cancel(request):
"""Cancel a pending crypto payment."""
payment_id = request.matchdict.get("payment_id")
if not payment_id:
return HTTPBadRequest("missing payment_id")
try:
pid = _uuid.UUID(payment_id)
except Exception:
return HTTPBadRequest("invalid payment_id")
crypto_payment = (
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
)
if not crypto_payment:
return HTTPBadRequest("payment not found")
# Check that user owns this payment
if crypto_payment.invoice.user_id != request.user.id:
return HTTPBadRequest("unauthorized")
# Can only cancel payments that are not already terminal
if crypto_payment.status in CryptoPayment.TERMINAL_STATUSES:
return HTTPBadRequest(
f"Cannot cancel payment with status: {crypto_payment.status}"
)
# Can only cancel if no funds received yet
if crypto_payment.received_amount > 0:
return HTTPBadRequest("Cannot cancel payment that has received funds")
# Cancel the payment
crypto_payment.status = CryptoPayment.STATUS_CANCELLED
request.dbsession.add(crypto_payment)
request.dbsession.flush()
# Get the cart URL for redirect
cart_url = request.route_url(
"user_cart_checkout", cart_id=crypto_payment.invoice.cart_id
)
return Response(
json.dumps(
{"success": True, "message": "Payment cancelled", "cart_url": cart_url}
),
content_type="application/json",
charset="utf-8",
)