Browse Source

Redesign portal invoice payments with Pay Now selector and crypto timers

main
def 7 days ago
parent
commit
e631309322
  1. 259
      backend/app.py
  2. 459
      templates/portal_invoice_detail.html

259
backend/app.py

@ -179,6 +179,61 @@ def fetch_oracle_quote_snapshot(currency_code, total_amount):
except Exception:
return None
def get_invoice_crypto_options(invoice):
oracle_quote = invoice.get("oracle_quote") or {}
raw_quotes = oracle_quote.get("quotes") or []
option_map = {
"USDC": {
"symbol": "USDC",
"chain": "arbitrum",
"label": "USDC (Arbitrum)",
"payment_currency": "USDC",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
},
"ETH": {
"symbol": "ETH",
"chain": "ethereum",
"label": "ETH (Ethereum)",
"payment_currency": "ETH",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
},
"ETHO": {
"symbol": "ETHO",
"chain": "etho",
"label": "ETHO (Etho)",
"payment_currency": "ETHO",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
},
"ETI": {
"symbol": "ETI",
"chain": "etica",
"label": "ETI (Etica)",
"payment_currency": "ETI",
"wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
},
}
options = []
for q in raw_quotes:
symbol = str(q.get("symbol") or "").upper()
if symbol not in option_map:
continue
if not q.get("display_amount"):
continue
opt = dict(option_map[symbol])
opt["display_amount"] = q.get("display_amount")
opt["crypto_amount"] = q.get("crypto_amount")
opt["price_cad"] = q.get("price_cad")
opt["recommended"] = bool(q.get("recommended"))
opt["available"] = bool(q.get("available"))
opt["reason"] = q.get("reason")
options.append(opt)
options.sort(key=lambda x: (0 if x.get("recommended") else 1, x.get("symbol")))
return options
def square_amount_to_cents(value):
return int((to_decimal(value) * 100).quantize(Decimal("1")))
@ -3785,6 +3840,77 @@ def portal_invoice_detail(invoice_id):
item["unit_price"] = _fmt_money(item.get("unit_price"))
item["line_total"] = _fmt_money(item.get("line_total"))
pay_mode = (request.args.get("pay") or "").strip().lower()
crypto_error = (request.args.get("crypto_error") or "").strip()
crypto_options = get_invoice_crypto_options(invoice)
selected_crypto_option = None
pending_crypto_payment = None
crypto_quote_window_expires_iso = None
crypto_quote_window_expires_local = None
if pay_mode == "crypto" and crypto_options and (invoice.get("status") or "").lower() != "paid":
quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}"
now_utc = datetime.now(timezone.utc)
stored_start = session.get(quote_key)
quote_start_dt = None
if stored_start:
try:
quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00"))
if quote_start_dt.tzinfo is None:
quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc)
except Exception:
quote_start_dt = None
if request.args.get("refresh_quote") == "1" or not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90:
quote_start_dt = now_utc
session[quote_key] = quote_start_dt.isoformat()
quote_expires_dt = quote_start_dt + timedelta(seconds=90)
crypto_quote_window_expires_iso = quote_expires_dt.astimezone(timezone.utc).isoformat()
crypto_quote_window_expires_local = fmt_local(quote_expires_dt)
selected_asset = (request.args.get("asset") or "").strip().upper()
if selected_asset:
selected_crypto_option = next((o for o in crypto_options if o["symbol"] == selected_asset), None)
payment_id = (request.args.get("payment_id") or "").strip()
if payment_id.isdigit():
cursor.execute("""
SELECT id, invoice_id, client_id, payment_currency, payment_amount, cad_value_at_payment,
reference, wallet_address, payment_status, created_at, notes
FROM payments
WHERE id = %s
AND invoice_id = %s
AND client_id = %s
LIMIT 1
""", (payment_id, invoice_id, client["id"]))
pending_crypto_payment = cursor.fetchone()
if pending_crypto_payment:
created_dt = pending_crypto_payment.get("created_at")
if created_dt and created_dt.tzinfo is None:
created_dt = created_dt.replace(tzinfo=timezone.utc)
if created_dt:
lock_expires_dt = created_dt + timedelta(minutes=2)
pending_crypto_payment["created_at_local"] = fmt_local(created_dt)
pending_crypto_payment["lock_expires_at_local"] = fmt_local(lock_expires_dt)
pending_crypto_payment["lock_expires_at_iso"] = lock_expires_dt.astimezone(timezone.utc).isoformat()
pending_crypto_payment["lock_expired"] = datetime.now(timezone.utc) >= lock_expires_dt
else:
pending_crypto_payment["created_at_local"] = ""
pending_crypto_payment["lock_expires_at_local"] = ""
pending_crypto_payment["lock_expires_at_iso"] = ""
pending_crypto_payment["lock_expired"] = True
if not selected_crypto_option:
selected_crypto_option = next(
(o for o in crypto_options if o["payment_currency"] == str(pending_crypto_payment.get("payment_currency") or "").upper()),
None
)
pdf_url = f"/invoices/pdf/{invoice_id}"
conn.close()
@ -3795,6 +3921,13 @@ def portal_invoice_detail(invoice_id):
invoice=invoice,
items=items,
pdf_url=pdf_url,
pay_mode=pay_mode,
crypto_error=crypto_error,
crypto_options=crypto_options,
selected_crypto_option=selected_crypto_option,
pending_crypto_payment=pending_crypto_payment,
crypto_quote_window_expires_iso=crypto_quote_window_expires_iso,
crypto_quote_window_expires_local=crypto_quote_window_expires_local,
)
@ -4185,6 +4318,132 @@ OutsideTheBox
@app.route("/portal/invoice/<int:invoice_id>/pay-crypto", methods=["POST"])
def portal_invoice_pay_crypto(invoice_id):
client = _portal_current_client()
if not client:
return redirect("/portal")
ensure_invoice_quote_columns()
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT id, client_id, invoice_number, status, total_amount, amount_paid,
quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot,
created_at
FROM invoices
WHERE id = %s AND client_id = %s
LIMIT 1
""", (invoice_id, client["id"]))
invoice = cursor.fetchone()
if not invoice:
conn.close()
return redirect("/portal/dashboard")
status = (invoice.get("status") or "").lower()
if status == "paid":
conn.close()
return redirect(f"/portal/invoice/{invoice_id}")
invoice["oracle_quote"] = None
if invoice.get("oracle_snapshot"):
try:
invoice["oracle_quote"] = json.loads(invoice["oracle_snapshot"])
except Exception:
invoice["oracle_quote"] = None
options = get_invoice_crypto_options(invoice)
chosen_symbol = (request.form.get("asset") or "").strip().upper()
selected_option = next((o for o in options if o["symbol"] == chosen_symbol), None)
quote_key = f"portal_crypto_quote_window_{invoice_id}_{client['id']}"
now_utc = datetime.now(timezone.utc)
stored_start = session.get(quote_key)
quote_start_dt = None
if stored_start:
try:
quote_start_dt = datetime.fromisoformat(str(stored_start).replace("Z", "+00:00"))
if quote_start_dt.tzinfo is None:
quote_start_dt = quote_start_dt.replace(tzinfo=timezone.utc)
except Exception:
quote_start_dt = None
if not quote_start_dt or (now_utc - quote_start_dt).total_seconds() > 90:
session.pop(quote_key, None)
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=price+has+expired+-+please+refresh+your+view+to+update&refresh_quote=1")
if not selected_option:
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error=Please+choose+a+valid+crypto+asset")
if not selected_option.get("available"):
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&crypto_error={selected_option['symbol']}+is+not+currently+available")
cursor.execute("""
SELECT id, payment_currency, payment_amount, wallet_address, reference, payment_status, created_at, notes
FROM payments
WHERE invoice_id = %s
AND client_id = %s
AND payment_status = 'pending'
AND payment_currency = %s
ORDER BY id DESC
LIMIT 1
""", (invoice_id, client["id"], selected_option["payment_currency"]))
existing = cursor.fetchone()
pending_payment_id = None
if existing:
created_dt = existing.get("created_at")
if created_dt and created_dt.tzinfo is None:
created_dt = created_dt.replace(tzinfo=timezone.utc)
if created_dt and (now_utc - created_dt).total_seconds() <= 120:
pending_payment_id = existing["id"]
if not pending_payment_id:
insert_cursor = conn.cursor()
insert_cursor.execute("""
INSERT INTO payments
(
invoice_id,
client_id,
payment_method,
payment_currency,
payment_amount,
cad_value_at_payment,
reference,
sender_name,
txid,
wallet_address,
payment_status,
received_at,
notes
)
VALUES (%s, %s, 'other', %s, %s, %s, %s, %s, NULL, %s, 'pending', NULL, %s)
""", (
invoice["id"],
invoice["client_id"],
selected_option["payment_currency"],
str(selected_option["display_amount"]),
str(invoice.get("quote_fiat_amount") or invoice.get("total_amount") or "0"),
invoice["invoice_number"],
client.get("email") or client.get("company_name") or "Portal Client",
selected_option["wallet_address"],
f"portal_crypto_intent:{selected_option['symbol']}:{selected_option['chain']}|invoice:{invoice['invoice_number']}|frozen_quote"
))
conn.commit()
pending_payment_id = insert_cursor.lastrowid
session.pop(quote_key, None)
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?pay=crypto&asset={selected_option['symbol']}&payment_id={pending_payment_id}")
@app.route("/portal/invoice/<int:invoice_id>/pay-square", methods=["GET"])
def portal_invoice_pay_square(invoice_id):
client = _portal_current_client()

459
templates/portal_invoice_detail.html

@ -26,6 +26,7 @@
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
margin-bottom: 1rem;
}
.detail-card h3 {
margin-top: 0;
@ -45,13 +46,6 @@
background: #e9eef7;
color: #10203f;
}
.invoice-actions {
margin-top: 1rem;
}
.invoice-actions a {
margin-right: 1rem;
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 0.18rem 0.55rem;
@ -75,6 +69,108 @@
background: rgba(148, 163, 184, 0.20);
color: #cbd5e1;
}
.pay-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
margin-top: 1.25rem;
}
.pay-selector-row {
display:flex;
gap:0.75rem;
align-items:center;
flex-wrap:wrap;
margin-top:0.75rem;
}
.pay-selector {
padding: 10px 12px;
min-width: 220px;
border-radius: 8px;
}
.pay-panel {
margin-top: 1rem;
padding: 1rem;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
background: rgba(255,255,255,0.02);
}
.pay-panel.hidden {
display: none;
}
.pay-btn {
display:inline-block;
padding:12px 18px;
color:#ffffff;
text-decoration:none;
border-radius:8px;
font-weight:700;
border:none;
cursor:pointer;
margin:8px 0 0 0;
}
.pay-btn-square {
background:#16a34a;
}
.pay-btn-crypto {
background:#2563eb;
}
.error-box {
border: 1px solid rgba(239, 68, 68, 0.55);
background: rgba(127, 29, 29, 0.22);
color: #fecaca;
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 1rem;
}
.snapshot-wrap {
position: relative;
margin-top: 1rem;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.02);
}
.snapshot-header {
display:flex;
justify-content:space-between;
gap:1rem;
align-items:flex-start;
}
.snapshot-meta {
flex: 1 1 auto;
min-width: 0;
line-height: 1.65;
}
.snapshot-timer-box {
width: 220px;
min-height: 132px;
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
background: rgba(0,0,0,0.18);
display:flex;
flex-direction:column;
justify-content:center;
align-items:center;
text-align:center;
padding: 0.9rem;
}
.snapshot-timer-value {
font-size: 2rem;
font-weight: 800;
line-height: 1.1;
}
.snapshot-timer-label {
margin-top: 0.55rem;
font-size: 0.95rem;
opacity: 0.95;
}
.snapshot-timer-expired {
color: #f87171;
}
.quote-table {
width: 100%;
border-collapse: collapse;
@ -84,16 +180,12 @@
padding: 0.75rem;
border-bottom: 1px solid rgba(255,255,255,0.12);
text-align: left;
vertical-align: top;
}
.quote-table th {
background: #e9eef7;
color: #10203f;
}
.quote-meta {
font-size: 0.95rem;
line-height: 1.6;
opacity: 0.95;
}
.quote-badge {
display: inline-block;
padding: 0.14rem 0.48rem;
@ -110,6 +202,58 @@
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.quote-pick-btn {
padding: 8px 12px;
border-radius: 8px;
border: none;
background: #2563eb;
color: #fff;
font-weight: 700;
cursor: pointer;
}
.quote-pick-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.lock-box {
margin-top: 1rem;
border: 1px solid rgba(34, 197, 94, 0.28);
background: rgba(22, 101, 52, 0.16);
border-radius: 12px;
padding: 1rem;
}
.lock-box.expired {
border-color: rgba(239, 68, 68, 0.55);
background: rgba(127, 29, 29, 0.22);
}
.lock-grid {
display:grid;
grid-template-columns: 1fr 220px;
gap:1rem;
align-items:start;
}
.lock-code {
display:block;
margin-top:0.35rem;
padding:0.65rem 0.8rem;
background: rgba(0,0,0,0.22);
border-radius: 8px;
overflow-wrap:anywhere;
}
@media (max-width: 820px) {
.snapshot-header,
.lock-grid {
grid-template-columns: 1fr;
display:block;
}
.snapshot-timer-box {
width: 100%;
margin-top: 1rem;
min-height: 110px;
}
}
</style>
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
@ -128,13 +272,16 @@
</div>
</div>
{% if (invoice.status or "")|lower == "paid" %}
<div style="background:#166534;color:#ecfdf5;padding:12px 14px;margin:0 0 20px 0;border-radius:8px;font-weight:600;border:1px solid rgba(255,255,255,0.12);">
✓ This invoice has been paid. Thank you!
</div>
{% endif %}
{% if crypto_error %}
<div class="error-box">{{ crypto_error }}</div>
{% endif %}
<div class="detail-grid">
<div class="detail-card">
<h3>Invoice</h3>
@ -197,86 +344,242 @@
</tbody>
</table>
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}
<div class="pay-card">
<h3>Pay Now</h3>
<div class="pay-selector-row">
<label for="payMethodSelect"><strong>Choose payment method:</strong></label>
<select id="payMethodSelect" class="pay-selector">
<option value="" {% if not pay_mode %}selected{% endif %}>Select…</option>
<option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option>
<option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option>
<option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option>
</select>
</div>
{% if (invoice.status or "")|lower != "paid" %}
<div class="detail-card" style="margin-top:1.25rem;">
<h3>Payment Instructions</h3>
<p><strong>Interac e-Transfer</strong><br>
Send payment to:<br>
payment@outsidethebox.top<br>
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}
</p>
<p><strong>Credit Card (Square)</strong><br>
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer"
style="display:inline-block;padding:12px 18px;background:#16a34a;color:#ffffff;text-decoration:none;border-radius:8px;font-weight:700;margin:8px 0;">
Pay Now
</a><br>
Please include your invoice number in the payment note.
</p>
<p>
If you have questions please contact
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">support@outsidethebox.top</a>
</p>
</div>
{% endif %}
<div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}">
<p><strong>Interac e-Transfer</strong><br>
Send payment to:<br>
payment@outsidethebox.top<br>
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p>
</div>
{% if invoice.oracle_quote and invoice.oracle_quote.quotes %}
<div class="detail-card" style="margin-top:1.25rem;">
<h3>Crypto Quote Snapshot</h3>
<div class="quote-meta">
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div>
<div><strong>Quote Expires:</strong> {{ invoice.quote_expires_at_local or (invoice.oracle_quote.expires_at or "—") }}</div>
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div>
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div>
<div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}">
<p><strong>Credit Card (Square)</strong></p>
<a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">
Pay with Credit Card
</a>
</div>
<table class="quote-table">
<thead>
<tr>
<th>Asset</th>
<th>Quoted Amount</th>
<th>CAD Price</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for q in invoice.oracle_quote.quotes %}
<tr>
<td>
{{ q.symbol }} {% if q.chain %}({{ q.chain }}){% endif %}
{% if q.recommended %}
<span class="quote-badge quote-live">recommended</span>
{% endif %}
</td>
<td>{{ q.display_amount or "—" }}</td>
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td>
<td>
{% if q.available %}
<span class="quote-badge quote-live">live</span>
<div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}">
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}
<div class="snapshot-wrap">
<div class="snapshot-header">
<div class="snapshot-meta">
<h3 style="margin-top:0;">Crypto Quote Snapshot</h3>
<div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div>
<div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div>
<div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div>
{% if pending_crypto_payment %}
<div style="margin-top:0.75rem;"><strong>Price locked for 2 minutes after acceptance.</strong></div>
{% else %}
<div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div>
{% endif %}
</div>
{% if pending_crypto_payment %}
<div class="snapshot-timer-box">
<div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div>
</div>
{% else %}
<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>
<div class="snapshot-timer-box">
<div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div>
<div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:0.85rem; opacity:0.9;">
These crypto values were frozen when the invoice was created and are retained for audit/reference.
</p>
</div>
{% if pending_crypto_payment and selected_crypto_option %}
<div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired %} expired{% endif %}">
<div class="lock-grid">
<div>
<h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3>
<div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div>
<div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div>
<code class="lock-code">{{ pending_crypto_payment.wallet_address }}</code>
<div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div>
<code class="lock-code">{{ pending_crypto_payment.reference }}</code>
<div style="margin-top:0.65rem;">
{% if pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% else %}
Your selected crypto quote has been accepted and placed into processing.
{% endif %}
</div>
</div>
<div class="snapshot-timer-box">
<div id="lockTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerSideLabel" class="snapshot-timer-label">This price is locked for 2 minutes</div>
</div>
</div>
</div>
{% else %}
<form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto">
<table class="quote-table">
<thead>
<tr>
<th>Asset</th>
<th>Quoted Amount</th>
<th>CAD Price</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for q in crypto_options %}
<tr>
<td>
{{ q.label }}
{% if q.recommended %}
<span class="quote-badge quote-live">recommended</span>
{% endif %}
</td>
<td>{{ q.display_amount or "—" }}</td>
<td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td>
<td>
{% if q.available %}
<span class="quote-badge quote-live">live</span>
{% else %}
<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>
{% endif %}
</td>
<td>
<button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>
Accept {{ q.symbol }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endif %}
</div>
{% else %}
<p>No crypto quote snapshot is available for this invoice yet.</p>
{% endif %}
</div>
</div>
{% endif %}
{% if pdf_url %}
<div class="invoice-actions">
<div style="margin-top:1rem;">
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a>
</div>
{% endif %}
</div>
<script>
(function() {
const select = document.getElementById("payMethodSelect");
if (select) {
select.addEventListener("change", function() {
const value = this.value || "";
const url = new URL(window.location.href);
if (!value) {
url.searchParams.delete("pay");
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.delete("refresh_quote");
} else {
url.searchParams.set("pay", value);
if (value !== "crypto") {
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.delete("refresh_quote");
} else {
url.searchParams.delete("asset");
url.searchParams.delete("payment_id");
url.searchParams.delete("crypto_error");
url.searchParams.set("refresh_quote", "1");
}
}
window.location.href = url.toString();
});
}
function bindCountdown(valueId, labelId, expireIso, expiredMessage) {
const valueEl = document.getElementById(valueId);
const labelEl = document.getElementById(labelId);
if (!valueEl || !expireIso) return;
function tick() {
const end = new Date(expireIso).getTime();
const now = Date.now();
const diff = Math.max(0, Math.floor((end - now) / 1000));
if (diff <= 0) {
valueEl.textContent = "00:00";
valueEl.classList.add("snapshot-timer-expired");
if (labelEl) {
labelEl.textContent = expiredMessage;
labelEl.classList.add("snapshot-timer-expired");
}
const cryptoForm = document.getElementById("cryptoPickForm");
if (cryptoForm) {
cryptoForm.querySelectorAll("button").forEach(btn => btn.disabled = true);
}
const lockBox = document.getElementById("lockBox");
if (lockBox) {
lockBox.classList.add("expired");
}
return;
}
const m = String(Math.floor(diff / 60)).padStart(2, "0");
const s = String(diff % 60).padStart(2, "0");
valueEl.textContent = `${m}:${s}`;
setTimeout(tick, 250);
}
tick();
}
const quoteTimer = document.getElementById("quoteTimerValue");
if (quoteTimer && quoteTimer.dataset.expiry) {
bindCountdown(
"quoteTimerValue",
"quoteTimerLabel",
quoteTimer.dataset.expiry,
"price has expired - please refresh your view to update"
);
}
const lockTimer = document.getElementById("lockTimerValue");
if (lockTimer && lockTimer.dataset.expiry) {
bindCountdown(
"lockTimerValue",
"lockTimerLabel",
lockTimer.dataset.expiry,
"price has expired - please refresh your quote to update"
);
}
const lockTimerSide = document.getElementById("lockTimerSideValue");
if (lockTimerSide && lockTimerSide.dataset.expiry) {
bindCountdown(
"lockTimerSideValue",
"lockTimerSideLabel",
lockTimerSide.dataset.expiry,
"price has expired - please refresh your quote to update"
);
}
})();
</script>
{% include "footer.html" %}
</body>
</html>

Loading…
Cancel
Save