Refactor blockonomics btc + qr code presentation

This commit is contained in:
David Bomba
2025-11-26 15:05:56 +11:00
parent 019a688047
commit 4e9a756d52
11 changed files with 345 additions and 91 deletions

View File

@@ -34,25 +34,5 @@ class BlockonomicsController extends Controller
return response()->json(['error' => 'Unable to fetch BTC price'], 500);
}
public function getQRCode(Request $request)
{
$qr_string = $request->query('qr_string');
$svg = $this->getPaymentQrCodeRaw($qr_string);
return response($svg)->header('Content-Type', 'image/svg+xml');
}
private function getPaymentQrCodeRaw($qr_string)
{
$renderer = new ImageRenderer(
new RendererStyle(150, margin: 0),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qr = $writer->writeString($qr_string, 'utf-8');
return $qr;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Payments;
use Livewire\Component;
use Illuminate\Support\Facades\Http;
class BlockonomicsPriceDisplay extends Component
{
public $currency;
public $btc_price;
public $btc_amount;
public $countdown = '10:00';
public $is_refreshing = false;
protected $listeners = ['refresh-btc-price' => 'refreshBTCPrice'];
public function mount($currency, $btc_price, $btc_amount)
{
$this->currency = $currency;
$this->btc_price = $btc_price;
$this->btc_amount = $btc_amount;
// Countdown will be initialized in the JavaScript @script section
}
public function refreshBTCPrice()
{
$this->is_refreshing = true;
nlog('Refreshing BTC price');
try {
$response = Http::get('https://www.blockonomics.co/api/price', [
'currency' => $this->currency,
]);
if ($response->successful()) {
$price = $response->object()->price ?? null;
if ($price) {
$this->btc_price = $price;
$this->btc_amount = number_format($this->btc_amount / $price, 10);
// Reset the countdown
$this->startCountdown();
$this->dispatch('btc-price-updated', [
'price' => $price,
'amount' => $this->btc_amount,
]);
}
}
nlog($response->body());
} catch (\Exception $e) {
nlog('Failed to refresh BTC price: ' . $e->getMessage());
} finally {
$this->is_refreshing = false;
}
}
public function startCountdown()
{
$this->countdown = '10:00';
$this->dispatch('start-countdown', ['duration' => 600]);
}
public function render()
{
return render('components.livewire.blockonomics-price-display');
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Payments;
use BaconQrCode\Writer;
use Livewire\Component;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
class BlockonomicsQrCode extends Component
{
public $btc_address;
public $btc_amount;
public $qr_code_svg = '';
public $is_loading = false;
public $error_message = '';
public function mount($btc_address, $btc_amount)
{
$this->btc_address = $btc_address;
$this->btc_amount = $btc_amount;
$this->fetchQRCode();
}
public function fetchQRCode($newBtcAmount = null)
{
$this->is_loading = true;
$this->error_message = '';
try {
$btcAmount = $newBtcAmount ?? $this->btc_amount;
$qrString = "bitcoin:{$this->btc_address}?amount={$btcAmount}";
$this->qr_code_svg = $this->getPaymentQrCodeRaw($qrString);
} catch (\Exception $e) {
$this->error_message = 'Error generating QR code';
} finally {
$this->is_loading = false;
}
}
private function getPaymentQrCodeRaw($qr_string)
{
$renderer = new ImageRenderer(
new RendererStyle(150, margin: 0),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qr = $writer->writeString($qr_string, 'utf-8');
return $qr;
}
public function updateQRCode($btcAmount)
{
$this->btc_amount = $btcAmount;
$this->fetchQRCode($btcAmount);
}
public function render()
{
return render('components.livewire.blockonomics-qr-code');
}
}

View File

@@ -0,0 +1,14 @@
var h=Object.defineProperty;var y=(i,e,t)=>e in i?h(i,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):i[e]=t;var u=(i,e,t)=>(y(i,typeof e!="symbol"?e+"":e,t),t);import{i as p,w}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/class b{constructor(){u(this,"startTimer",()=>{});this.copyToClipboard=this.copyToClipboard.bind(this),this.refreshBTCPrice=this.refreshBTCPrice.bind(this),this.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode.bind(this),this.startTimer=this.startTimer.bind(this)}copyToClipboard(e,t,n){const o=n?t.nextElementSibling:t,c=o.src,s=document.createElement("input"),r=document.getElementById(e),{value:a,innerText:d}=r||{},l=a||d;s.value=l,document.body.appendChild(s),s.select(),document.execCommand("copy"),document.body.removeChild(s),o.src="data:image/svg+xml;base64,"+btoa(`
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04706 14C4.04706 8.55609 8.46025 4.1429 13.9042 4.1429C19.3482 4.1429 23.7613 8.55609 23.7613 14C23.7613 19.444 19.3482 23.8572 13.9042 23.8572C8.46025 23.8572 4.04706 19.444 4.04706 14Z" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.52325 14L12.809 17.2858L18.2852 11.8096" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`),setTimeout(()=>{o.src=c},5e3)}async fetchAndDisplayQRCode(){}async refreshBTCPrice(){const e=document.querySelector(".icon-refresh");e.classList.add("rotating"),document.getElementsByClassName("btc-value")[0].innerHTML="Refreshing...";const t=async()=>{try{const n=document.querySelector('meta[name="currency"]').content,o=await fetch(`/api/v1/get-btc-price?currency=${n}`);if(!o.ok)throw new Error("Network response was not ok");return(await o.json()).price}catch(n){console.error("There was a problem with the BTC price fetch operation:",n)}};try{const n=await t();if(n){const o=document.querySelector('meta[name="currency"]').content;document.getElementsByClassName("btc-value")[0].innerHTML="1 BTC = "+(n||"N/A")+" "+o+", updates in <span id='countdown'></span>";const c=(document.querySelector('meta[name="amount"]').content/n).toFixed(10);document.querySelector('input[name="btc_price"]').value=n,document.querySelector('input[name="btc_amount"]').value=c,document.getElementById("btc-amount").textContent=c;const s=document.querySelector('meta[name="btc_address"]').content,r=document.getElementById("qr-code-link"),a=document.getElementById("open-in-wallet-link");r.href=`bitcoin:${s}?amount=${c}`,a.href=`bitcoin:${s}?amount=${c}`,await this.fetchAndDisplayQRCode(c),this.startTimer(600)}}finally{e.classList.remove("rotating")}}handle(){window.copyToClipboard=this.copyToClipboard,window.refreshBTCPrice=this.refreshBTCPrice,window.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode,window.startTimer=this.startTimer;const e=()=>{const t=document.querySelector('meta[name="btc_address"]').content,n=`wss://www.blockonomics.co/payment/${t}`,o=new WebSocket(n);o.onmessage=function(c){const s=JSON.parse(c.data),{status:r,txid:a,value:d}=s||{};console.log("Payment status:",r),(r===0||r===1||r===2)&&(document.querySelector('input[name="txid"]').value=a||"",document.querySelector('input[name="status"]').value=r||"",document.querySelector('input[name="btc_amount"]').value=d||"",document.querySelector('input[name="btc_address"]').value=t||"",document.getElementById("server-response").submit())}};startTimer(600),e(),fetchAndDisplayQRCode()}}function m(){new b().handle(),window.bootBlockonomics=m}p()?m():w("#blockonomics-payment").then(()=>m());

View File

@@ -1,14 +0,0 @@
var h=Object.defineProperty;var y=(a,t,e)=>t in a?h(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var l=(a,t,e)=>(y(a,typeof t!="symbol"?t+"":t,e),e);import{i as w,w as p}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/class f{constructor(){l(this,"startTimer",t=>{const e=new Date().getTime()+t*1e3;document.getElementById("countdown").innerHTML="10:00 min";const o=()=>{const c=new Date().getTime(),n=e-c;if(document.getElementsByClassName("btc-value")[0].innerHTML.includes("Refreshing"))return;if(n<0){refreshBTCPrice();return}const s=Math.floor(n%(1e3*60*60)/(1e3*60)),i=Math.floor(n%(1e3*60)/1e3),d=String(s).padStart(2,"0"),m=String(i).padStart(2,"0");document.getElementById("countdown").innerHTML=d+":"+m+" min"};clearInterval(window.countdownInterval),window.countdownInterval=setInterval(o,1e3)});this.copyToClipboard=this.copyToClipboard.bind(this),this.refreshBTCPrice=this.refreshBTCPrice.bind(this),this.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode.bind(this),this.startTimer=this.startTimer.bind(this)}copyToClipboard(t,e,o){const c=o?e.nextElementSibling:e,n=c.src,r=document.createElement("input"),s=document.getElementById(t),{value:i,innerText:d}=s||{},m=i||d;r.value=m,document.body.appendChild(r),r.select(),document.execCommand("copy"),document.body.removeChild(r),c.src="data:image/svg+xml;base64,"+btoa(`
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04706 14C4.04706 8.55609 8.46025 4.1429 13.9042 4.1429C19.3482 4.1429 23.7613 8.55609 23.7613 14C23.7613 19.444 19.3482 23.8572 13.9042 23.8572C8.46025 23.8572 4.04706 19.444 4.04706 14Z" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.52325 14L12.809 17.2858L18.2852 11.8096" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`),setTimeout(()=>{c.src=n},5e3)}async fetchAndDisplayQRCode(t=null){try{const e=document.querySelector('meta[name="btc_address"]').content,c=encodeURIComponent(`bitcoin:${e}?amount=${t||"{{$btc_amount}}"}`),n=await fetch(`/api/v1/get-blockonomics-qr-code?qr_string=${c}`);if(!n.ok)throw new Error(`HTTP error! status: ${n.status}`);const r=await n.text();document.getElementById("qrcode-container").innerHTML=r}catch(e){console.error("Error fetching QR code:",e),document.getElementById("qrcode-container").textContent="Error loading QR code"}}async refreshBTCPrice(){const t=document.querySelector(".icon-refresh");t.classList.add("rotating"),document.getElementsByClassName("btc-value")[0].innerHTML="Refreshing...";const e=async()=>{try{const o=document.querySelector('meta[name="currency"]').content,c=await fetch(`/api/v1/get-btc-price?currency=${o}`);if(!c.ok)throw new Error("Network response was not ok");return(await c.json()).price}catch(o){console.error("There was a problem with the BTC price fetch operation:",o)}};try{const o=await e();if(o){const c=document.querySelector('meta[name="currency"]').content;document.getElementsByClassName("btc-value")[0].innerHTML="1 BTC = "+(o||"N/A")+" "+c+", updates in <span id='countdown'></span>";const n=(document.querySelector('meta[name="amount"]').content/o).toFixed(10);document.querySelector('input[name="btc_price"]').value=o,document.querySelector('input[name="btc_amount"]').value=n,document.getElementById("btc-amount").textContent=n;const r=document.querySelector('meta[name="btc_address"]').content,s=document.getElementById("qr-code-link"),i=document.getElementById("open-in-wallet-link");s.href=`bitcoin:${r}?amount=${n}`,i.href=`bitcoin:${r}?amount=${n}`,await this.fetchAndDisplayQRCode(n),this.startTimer(600)}}finally{t.classList.remove("rotating")}}handle(){window.copyToClipboard=this.copyToClipboard,window.refreshBTCPrice=this.refreshBTCPrice,window.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode,window.startTimer=this.startTimer;const t=()=>{const e=document.querySelector('meta[name="btc_address"]').content,o=`wss://www.blockonomics.co/payment/${e}`,c=new WebSocket(o);c.onmessage=function(n){const r=JSON.parse(n.data),{status:s,txid:i,value:d}=r||{};console.log("Payment status:",s),(s===0||s===1||s===2)&&(document.querySelector('input[name="txid"]').value=i||"",document.querySelector('input[name="status"]').value=s||"",document.querySelector('input[name="btc_amount"]').value=d||"",document.querySelector('input[name="btc_address"]').value=e||"",document.getElementById("server-response").submit())}};startTimer(600),t(),fetchAndDisplayQRCode()}}function u(){new f().handle(),window.bootBlockonomics=u}w()?u():p("#blockonomics-payment").then(()=>u());

View File

@@ -99,7 +99,7 @@
"src": "resources/js/clients/payments/authorize-credit-card-payment.js"
},
"resources/js/clients/payments/blockonomics.js": {
"file": "assets/blockonomics-c3966bec.js",
"file": "assets/blockonomics-bab011a6.js",
"imports": [
"_wait-8f4ae121.js"
],

View File

@@ -50,50 +50,14 @@ class Blockonomics {
}
async fetchAndDisplayQRCode (newBtcAmount = null) {
try {
const btcAddress = document.querySelector('meta[name="btc_address"]').content;
const btcAmount = newBtcAmount || '{{$btc_amount}}';
const qrString = encodeURIComponent(`bitcoin:${btcAddress}?amount=${btcAmount}`);
const response = await fetch(`/api/v1/get-blockonomics-qr-code?qr_string=${qrString}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const svgText = await response.text();
document.getElementById('qrcode-container').innerHTML = svgText;
} catch (error) {
console.error('Error fetching QR code:', error);
document.getElementById('qrcode-container').textContent = 'Error loading QR code';
}
// QR code fetching is now handled by Livewire component (BlockonomicsQRCode)
async fetchAndDisplayQRCode () {
// This method is deprecated - use Livewire component instead
};
startTimer = (seconds) => {
const countDownDate = new Date().getTime() + seconds * 1000;
document.getElementById("countdown").innerHTML = "10" + ":" + "00" + " min";
const updateCountdown = () => {
const now = new Date().getTime();
const distance = countDownDate - now;
const isRefreshing = document.getElementsByClassName("btc-value")[0].innerHTML.includes("Refreshing");
if (isRefreshing) {
return;
}
if (distance < 0) {
refreshBTCPrice();
return;
}
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(seconds).padStart(2, '0');
document.getElementById("countdown").innerHTML = formattedMinutes + ":" + formattedSeconds + " min";
}
clearInterval(window.countdownInterval);
window.countdownInterval = setInterval(updateCountdown, 1000);
// Countdown timer is now handled by Livewire component (BlockonomicsPriceDisplay)
startTimer = () => {
// This method is deprecated - use Livewire component instead
}

View File

@@ -0,0 +1,98 @@
<div class="btc-value-wrapper">
<div class="btc-value">
1 BTC = {{ $btc_price }} {{ $currency }}, updates in <span id="countdown-livewire">{{ $countdown }}</span>
</div>
<span class="icon-refresh {{ $is_refreshing ? 'rotating' : '' }}" wire:click="refreshBTCPrice"
{{ $is_refreshing ? 'style="pointer-events: none;"' : '' }}></span>
<style>
.btc-value-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.btc-value {
font-size: 14px;
text-align: center;
}
.icon-refresh {
cursor: pointer;
margin-left: 5px;
width: 28px;
display: flex;
font-size: 32px;
margin-bottom: 5px;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.rotating {
animation: rotating 2s linear infinite;
}
</style>
@script
<script>
let countdownInterval = null;
let countdownDate = null;
const updateCountdown = () => {
if (!countdownDate) return;
const now = new Date().getTime();
const distance = countdownDate - now;
if (distance <= 0) {
clearInterval(countdownInterval);
countdownInterval = null;
$wire.refreshBTCPrice();
return;
}
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
if (!isNaN(minutes) && !isNaN(seconds)) {
const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(seconds).padStart(2, '0');
const countdownElement = document.getElementById('countdown-livewire');
if (countdownElement) {
countdownElement.textContent = formattedMinutes + ':' + formattedSeconds;
}
}
};
const startCountdownTimer = ({ duration }) => {
clearInterval(countdownInterval);
countdownInterval = null;
countdownDate = new Date().getTime() + duration * 1000;
updateCountdown();
countdownInterval = setInterval(updateCountdown, 1000);
};
$wire.on('start-countdown', startCountdownTimer);
// Listen for updates after price refresh
$wire.on('btc-price-updated', () => {
startCountdownTimer({ duration: 600 });
});
// Initialize countdown on component mount with a small delay to ensure DOM is ready
setTimeout(() => {
startCountdownTimer({ duration: 600 });
}, 100);
</script>
@endscript
</div>

View File

@@ -0,0 +1,41 @@
<div class="qrcode-wrapper">
@if ($error_message)
<div class="error-message">{{ $error_message }}</div>
@elseif ($is_loading)
<div class="loading-message">Loading QR code...</div>
@else
<a href="bitcoin:{{ $btc_address }}?amount={{ $btc_amount }}" id="qr-code-link" target="_blank">
<div id="qrcode-container">
{!! $qr_code_svg !!}
</div>
</a>
@endif
<style>
.qrcode-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.error-message {
color: #d32f2f;
padding: 20px;
text-align: center;
font-size: 14px;
}
.loading-message {
color: #666;
padding: 20px;
text-align: center;
font-size: 14px;
}
#qrcode-container {
display: flex;
justify-content: center;
align-items: center;
}
</style>
</div>

View File

@@ -20,9 +20,10 @@
<div class="scan-section">
<div class="title">Scan</div>
<span class="input-wrapper">
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" id="qr-code-link" target="_blank">
<div id="qrcode-container"></div>
</a>
<livewire:billing-portal.payments.blockonomics-qr-code
:btc_address="$btc_address"
:btc_amount="$btc_amount"
/>
</span>
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" target="_blank" id="open-in-wallet-link">Open in Wallet</a>
</div>
@@ -40,10 +41,11 @@
</div>
<img onclick='copyToClipboard("btc-amount", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
</span>
<div class="btc-value-wrapper">
<div class="btc-value">1 BTC = {{$btc_price}} {{$currency}}, updates in <span id="countdown"></span></div>
<span class="icon-refresh" onclick='refreshBTCPrice()'></span>
</div>
<livewire:billing-portal.payments.blockonomics-price-display
:currency="$currency"
:btc_price="$btc_price"
:btc_amount="$btc_amount"
/>
</div>
</div>
</div>

View File

@@ -19,9 +19,10 @@
<div class="scan-section">
<div class="title">Scan</div>
<span class="input-wrapper">
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" id="qr-code-link" target="_blank">
<div id="qrcode-container"></div>
</a>
<livewire:billing-portal.payments.blockonomics-qr-code
:btc_address="$btc_address"
:btc_amount="$btc_amount"
/>
</span>
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" target="_blank" id="open-in-wallet-link">Open in Wallet</a>
</div>
@@ -39,10 +40,11 @@
</div>
<img onclick='copyToClipboard("btc-amount", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
</span>
<div class="btc-value-wrapper">
<div class="btc-value">1 BTC = {{$btc_price}} {{$currency}}, updates in <span id="countdown"></span></div>
<span class="icon-refresh" onclick='refreshBTCPrice()'></span>
</div>
<livewire:billing-portal.payments.blockonomics-price-display
:currency="$currency"
:btc_price="$btc_price"
:btc_amount="$btc_amount"
/>
</div>
</div>
</div>