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:
parent
4be33ee528
commit
54da2d726d
7 changed files with 208 additions and 42 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 -%}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@
|
|||
|
||||
<style>
|
||||
.radio-group {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue