@ -7,252 +7,59 @@
< link rel = "stylesheet" href = "/static/css/style.css" >
< style >
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; }
.portal-top {
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap;
margin-bottom: 1rem;
}
.portal-actions a {
margin-left: 0.75rem;
text-decoration: underline;
}
.detail-grid {
display:grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap:1rem;
margin: 1rem 0 1.25rem 0;
}
.detail-card {
border: 1px solid rgba(255,255,255,0.16);
border-radius: 14px;
padding: 1rem;
background: rgba(255,255,255,0.03);
margin-bottom: 1rem;
}
.detail-card h3 {
margin-top: 0;
margin-bottom: 0.4rem;
}
table.portal-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
table.portal-table th, table.portal-table td {
padding: 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.12);
text-align: left;
}
table.portal-table th {
background: #e9eef7;
color: #10203f;
}
.status-badge {
display: inline-block;
padding: 0.18rem 0.55rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 700;
}
.status-paid {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.status-pending {
background: rgba(245, 158, 11, 0.20);
color: #fbbf24;
}
.status-overdue {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.status-other {
background: rgba(148, 163, 184, 0.20);
color: #cbd5e1;
}
.portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; }
.portal-actions a { margin-left: 0.75rem; text-decoration: underline; }
.detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; }
.detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; }
.detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; }
table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; }
table.portal-table th { background: #e9eef7; color: #10203f; }
.status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; }
.status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; }
.status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; }
.status-other { 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;
}
.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-wallet { 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; }
.success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; 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;
}
.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;
margin-top: 1rem;
}
.quote-table th, .quote-table td {
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-badge {
display: inline-block;
padding: 0.14rem 0.48rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
margin-left: 0.4rem;
}
.quote-live {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.quote-stale {
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;
}
.quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.quote-table th, .quote-table td { 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-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; }
.quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.quote-stale { 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;
}
.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; }
.wallet-actions { display:flex; gap:0.75rem; flex-wrap:wrap; margin-top:0.9rem; align-items:center; }
.wallet-note { opacity:0.9; margin-top:0.65rem; }
.mono { font-family: monospace; }
@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;
}
.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" >
@ -273,9 +80,7 @@
< / 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 >
< div class = "success-box" > ✓ This invoice has been paid. Thank you!< / div >
{% endif %}
{% if crypto_error %}
@ -283,63 +88,29 @@
{% endif %}
< div class = "detail-grid" >
< div class = "detail-card" >
< h3 > Invoice< / h3 >
< div > {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}< / div >
< / div >
< div class = "detail-card" > < h3 > Invoice< / h3 > < div > {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}< / div > < / div >
< div class = "detail-card" >
< h3 > Status< / h3 >
{% set s = (invoice.status or "")|lower %}
{% if s == "paid" %}
< span class = "status-badge status-paid" > {{ invoice.status }}< / span >
{% elif s == "pending" %}
< span class = "status-badge status-pending" > {{ invoice.status }}< / span >
{% elif s == "overdue" %}
< span class = "status-badge status-overdue" > {{ invoice.status }}< / span >
{% else %}
< span class = "status-badge status-other" > {{ invoice.status }}< / span >
{% endif %}
< / div >
< div class = "detail-card" >
< h3 > Created< / h3 >
< div > {{ invoice.created_at }}< / div >
< / div >
< div class = "detail-card" >
< h3 > Total< / h3 >
< div > {{ invoice.total_amount }}< / div >
< / div >
< div class = "detail-card" >
< h3 > Paid< / h3 >
< div > {{ invoice.amount_paid }}< / div >
< / div >
< div class = "detail-card" >
< h3 > Outstanding< / h3 >
< div > {{ invoice.outstanding }}< / div >
{% if s == "paid" %}< span class = "status-badge status-paid" > {{ invoice.status }}< / span >
{% elif s == "pending" %}< span class = "status-badge status-pending" > {{ invoice.status }}< / span >
{% elif s == "overdue" %}< span class = "status-badge status-overdue" > {{ invoice.status }}< / span >
{% else %}< span class = "status-badge status-other" > {{ invoice.status }}< / span > {% endif %}
< / div >
< div class = "detail-card" > < h3 > Created< / h3 > < div > {{ invoice.created_at }}< / div > < / div >
< div class = "detail-card" > < h3 > Total< / h3 > < div > {{ invoice.total_amount }}< / div > < / div >
< div class = "detail-card" > < h3 > Paid< / h3 > < div > {{ invoice.amount_paid }}< / div > < / div >
< div class = "detail-card" > < h3 > Outstanding< / h3 > < div > {{ invoice.outstanding }}< / div > < / div >
< / div >
< h2 > Invoice Items< / h2 >
< table class = "portal-table" >
< thead >
< tr >
< th > Description< / th >
< th > Qty< / th >
< th > Unit Price< / th >
< th > Line Total< / th >
< / tr >
< / thead >
< thead > < tr > < th > Description< / th > < th > Qty< / th > < th > Unit Price< / th > < th > Line Total< / th > < / tr > < / thead >
< tbody >
{% for item in items %}
< tr >
< td > {{ item.description }}< / td >
< td > {{ item.quantity }}< / td >
< td > {{ item.unit_price }}< / td >
< td > {{ item.line_total }}< / td >
< / tr >
< tr > < td > {{ item.description }}< / td > < td > {{ item.quantity }}< / td > < td > {{ item.unit_price }}< / td > < td > {{ item.line_total }}< / td > < / tr >
{% else %}
< tr >
< td colspan = "4" > No invoice line items found.< / td >
< / tr >
< tr > < td colspan = "4" > No invoice line items found.< / td > < / tr >
{% endfor %}
< / tbody >
< / table >
@ -358,17 +129,12 @@
< / div >
< 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 >
< 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 >
< 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 >
< 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 >
< div id = "panel-crypto" class = "pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}" >
@ -387,7 +153,12 @@
{% endif %}
< / div >
{% if pending_crypto_payment %}
{% if pending_crypto_payment and pending_crypto_payment.txid %}
< div class = "snapshot-timer-box" >
< div id = "processingTimerValue" class = "snapshot-timer-value" data-expiry = "{{ pending_crypto_payment.processing_expires_at_iso }}" > --:--< / div >
< div id = "processingTimerLabel" class = "snapshot-timer-label" > Waiting for network confirmation< / div >
< / div >
{% elif 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 >
@ -401,7 +172,7 @@
< / div >
{% if pending_crypto_payment and selected_crypto_option %}
< div id = "lockBox" class = "lock-box{% if pending_crypto_payment.lock_expired %} expired{% endif %}" >
< div id = "lockBox" class = "lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}" >
< div class = "lock-grid" >
< div >
< h3 style = "margin-top:0;" > {{ selected_crypto_option.label }} Payment Instructions< / h3 >
@ -410,55 +181,68 @@
< 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.
{% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
< div class = "wallet-actions" >
< button
type="button"
id="walletPayButton"
class="pay-btn pay-btn-wallet"
data-invoice-id="{{ invoice.id }}"
data-payment-id="{{ pending_crypto_payment.id }}"
data-asset="{{ selected_crypto_option.symbol }}"
data-chain-id="{{ selected_crypto_option.chain_id }}"
data-asset-type="{{ selected_crypto_option.asset_type }}"
data-to="{{ selected_crypto_option.wallet_address }}"
data-amount="{{ pending_crypto_payment.payment_amount }}"
data-decimals="{{ selected_crypto_option.decimals }}"
data-token-contract="{{ selected_crypto_option.token_contract or '' }}"
>
Pay with MetaMask / Rabby
< / button >
< span id = "walletStatusText" > < / span >
< / div >
< div class = "wallet-note" >
This will open your wallet, prepare the exact transaction, and submit the tx hash back to the portal automatically.
< / div >
{% elif pending_crypto_payment.txid %}
< div style = "margin-top:0.9rem;" > < strong > Transaction Hash:< / strong > < / div >
< code class = "lock-code mono" > {{ pending_crypto_payment.txid }}< / code >
< div style = "margin-top:0.75rem;" > Transaction submitted and detected on RPC. Waiting for network confirmation.< / div >
{% elif pending_crypto_payment.lock_expired %}
< div style = "margin-top:0.75rem;" > price has expired - please refresh your quote to update< / div >
{% endif %}
< / div >
{% if pending_crypto_payment and pending_crypto_payment.txid %}
< div class = "snapshot-timer-box" >
< div id = "processingTimerSideValue" class = "snapshot-timer-value" data-expiry = "{{ pending_crypto_payment.processing_expires_at_iso }}" > --:--< / div >
< div id = "processingTimerSideLabel" class = "snapshot-timer-label" > Waiting for network confirmation< / div >
< / div >
{% else %}
< 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 >
{% endif %}
< / 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 >
< 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 %}
{% if q.recommended %}< span class = "quote-badge quote-live" > recommended< / span > {% endif %}
{% if q.wallet_capable %}< span class = "quote-badge quote-live" > wallet< / 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 >
< 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 >
@ -474,9 +258,7 @@
{% endif %}
{% if pdf_url %}
< div style = "margin-top:1rem;" >
< a href = "/portal/invoice/{{ invoice.id }}/pdf" target = "_blank" rel = "noopener noreferrer" > Open Invoice PDF< / a >
< / div >
< div style = "margin-top:1rem;" > < a href = "/portal/invoice/{{ invoice.id }}/pdf" target = "_blank" rel = "noopener noreferrer" > Open Invoice PDF< / a > < / div >
{% endif %}
< / div >
@ -511,7 +293,7 @@
});
}
function bindCountdown(valueId, labelId, expireIso, expiredMessage) {
function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector ) {
const valueEl = document.getElementById(valueId);
const labelEl = document.getElementById(labelId);
if (!valueEl || !expireIso) return;
@ -528,14 +310,11 @@
labelEl.textContent = expiredMessage;
labelEl.classList.add("snapshot-timer-expired");
}
const cryptoForm = document.getElementById("cryptoPickForm");
if (cryptoForm) {
cryptoForm.querySelectorAll("button").forEach(btn => btn.disabled = true);
if (disableSelector) {
document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true);
}
const lockBox = document.getElementById("lockBox");
if (lockBox) {
lockBox.classList.add("expired");
}
if (lockBox) lockBox.classList.add("expired");
return;
}
@ -550,32 +329,116 @@
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"
);
bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button");
}
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"
);
bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton");
}
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"
);
bindCountdown("lockTimerSideValue", "lockTimerSideLabel", lockTimerSide.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton");
}
const processingTimer = document.getElementById("processingTimerValue");
if (processingTimer & & processingTimer.dataset.expiry) {
bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null);
}
const processingTimerSide = document.getElementById("processingTimerSideValue");
if (processingTimerSide & & processingTimerSide.dataset.expiry) {
bindCountdown("processingTimerSideValue", "processingTimerSideLabel", processingTimerSide.dataset.expiry, "price has expired - please refresh your quote to update", null);
}
function toHexBigIntFromDecimal(amountText, decimals) {
const text = String(amountText || "0");
const parts = text.split(".");
const whole = parts[0] || "0";
const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals);
const combined = (whole + frac).replace(/^0+/, "") || "0";
return "0x" + BigInt(combined).toString(16);
}
function erc20TransferData(to, amountText, decimals) {
const method = "a9059cbb";
const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0");
const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0");
return "0x" + method + addr + amtHex;
}
async function switchChain(chainId) {
const hexChainId = "0x" + Number(chainId).toString(16);
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: hexChainId }]
});
}
const walletButton = document.getElementById("walletPayButton");
if (walletButton) {
walletButton.addEventListener("click", async function() {
const statusEl = document.getElementById("walletStatusText");
const originalText = walletButton.textContent;
walletButton.disabled = true;
if (statusEl) statusEl.textContent = "Opening wallet…";
try {
if (!window.ethereum) {
throw new Error("No wallet detected. Open this page in MetaMask or Rabby.");
}
const invoiceId = walletButton.dataset.invoiceId;
const paymentId = walletButton.dataset.paymentId;
const asset = walletButton.dataset.asset;
const chainId = walletButton.dataset.chainId;
const assetType = walletButton.dataset.assetType;
const to = walletButton.dataset.to;
const amount = walletButton.dataset.amount;
const decimals = Number(walletButton.dataset.decimals || "18");
const tokenContract = walletButton.dataset.tokenContract || "";
await window.ethereum.request({ method: "eth_requestAccounts" });
await switchChain(chainId);
let txHash;
if (assetType === "native") {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{ to: to, value: toHexBigIntFromDecimal(amount, decimals) }]
});
} else {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{ to: tokenContract, data: erc20TransferData(to, amount, decimals) }]
});
}
if (statusEl) statusEl.textContent = "Submitting transaction hash to portal…";
const resp = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_id: paymentId,
asset: asset,
tx_hash: txHash
})
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
throw new Error((data & & (data.detail || data.error)) || "Portal rejected tx hash");
}
window.location.href = data.redirect_url;
} catch (err) {
if (statusEl) statusEl.textContent = String(err.message || err);
walletButton.disabled = false;
walletButton.textContent = originalText;
}
});
}
})();
< / script >