mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2026-01-03 14:14:28 +01:00
Fixes a regression introduced in #6316: Moving the `checkout-payment-method` integration point outside the div above broke the UI for the SiodeShift, FixedFloat and Trocador plugins. Also updated the URL change handler, so that it works for all plugins.
482 lines
19 KiB
JavaScript
482 lines
19 KiB
JavaScript
// These are the legacy states, see InvoiceEntity
|
|
const STATUS_PAYABLE = ['New'];
|
|
const STATUS_PAID = ['Processing'];
|
|
const STATUS_SETTLED = ['Settled'];
|
|
const STATUS_INVALID = ['Expired', 'Invalid'];
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
class NDEFReaderWrapper {
|
|
constructor() {
|
|
this.onreading = null;
|
|
this.onreadingerror = null;
|
|
}
|
|
|
|
async scan(opts) {
|
|
if (opts && opts.signal){
|
|
opts.signal.addEventListener('abort', () => {
|
|
window.parent.postMessage('nfc:abort', '*');
|
|
});
|
|
}
|
|
window.parent.postMessage('nfc:startScan', '*');
|
|
}
|
|
}
|
|
|
|
function computeStartingLanguage() {
|
|
const lang = urlParams.get('lang')
|
|
if (lang && isLanguageAvailable(lang)) return lang;
|
|
const { defaultLang } = initialSrvModel;
|
|
return isLanguageAvailable(defaultLang) ? defaultLang : fallbackLanguage;
|
|
}
|
|
|
|
function isLanguageAvailable(languageCode) {
|
|
return availableLanguages.includes(languageCode);
|
|
}
|
|
|
|
function updateLanguageSelect() {
|
|
// calculate and set width, as we want it center aligned
|
|
const $languageSelect = document.getElementById('DefaultLang');
|
|
const element = document.createElement('div');
|
|
element.innerText = $languageSelect.querySelector('option:checked').text;
|
|
$languageSelect.parentElement.appendChild(element);
|
|
const width = element.offsetWidth;
|
|
$languageSelect.parentElement.removeChild(element);
|
|
if (width && width > 0) {
|
|
$languageSelect.style.setProperty('--text-width', `${width}px`);
|
|
} else { // in case of modal this might not be rendered properly yet
|
|
window.requestAnimationFrame(updateLanguageSelect);
|
|
}
|
|
}
|
|
|
|
function updateLanguage(lang) {
|
|
if (isLanguageAvailable(lang)) {
|
|
i18next.changeLanguage(lang);
|
|
urlParams.set('lang', lang);
|
|
window.history.replaceState({}, '', `${location.pathname}?${urlParams}`);
|
|
updateLanguageSelect();
|
|
}
|
|
}
|
|
|
|
function asNumber(val) {
|
|
return val && parseFloat(val.toString().replace(/\s/g, '')); // e.g. sats are formatted with spaces: 1 000 000
|
|
}
|
|
|
|
Vue.use(VueI18next);
|
|
|
|
const fallbackLanguage = 'en';
|
|
const startingLanguage = computeStartingLanguage();
|
|
const i18n = new VueI18next(i18next);
|
|
|
|
const PaymentDetails = {
|
|
template: '#payment-details',
|
|
props: {
|
|
srvModel: Object,
|
|
isActive: Boolean,
|
|
showRecommendedFee: Boolean,
|
|
orderAmount: Number,
|
|
paid: Number,
|
|
due: Number
|
|
},
|
|
methods: {
|
|
asNumber
|
|
}
|
|
}
|
|
|
|
function initApp() {
|
|
return new Vue({
|
|
i18n,
|
|
el: '#Checkout',
|
|
components: {
|
|
'payment-details': PaymentDetails,
|
|
},
|
|
data () {
|
|
const srvModel = initialSrvModel;
|
|
return {
|
|
srvModel,
|
|
audioContext: window.AudioContext ? new AudioContext() : null,
|
|
displayPaymentDetails: false,
|
|
remainingSeconds: srvModel.expirationSeconds,
|
|
emailAddressInput: "",
|
|
emailAddressInputDirty: false,
|
|
emailAddressInputInvalid: false,
|
|
paymentMethodId: null,
|
|
endData: null,
|
|
isModal: srvModel.isModal,
|
|
pollTimeoutID: null,
|
|
paymentSound: null,
|
|
nfcReadSound: null,
|
|
errorSound: null,
|
|
nfc: {
|
|
supported: 'NDEFReader' in window,
|
|
scanning: false,
|
|
submitting: false,
|
|
errorMessage: null,
|
|
warningMessage: null,
|
|
permissionGranted: false,
|
|
readerAbortController: null
|
|
}
|
|
}
|
|
},
|
|
computed: {
|
|
isInvalid () {
|
|
return STATUS_INVALID.includes(this.srvModel.status);
|
|
},
|
|
isSettled () {
|
|
return STATUS_SETTLED.includes(this.srvModel.status);
|
|
},
|
|
isProcessing () {
|
|
return STATUS_PAID.includes(this.srvModel.status);
|
|
},
|
|
isActive () {
|
|
return STATUS_PAYABLE.includes(this.srvModel.status);
|
|
},
|
|
isPaidPartial () {
|
|
return this.paid > 0 && this.due > 0;
|
|
},
|
|
showInfo () {
|
|
return this.showTimer || this.showPaymentDueInfo;
|
|
},
|
|
showTimer () {
|
|
return this.isActive && this.remainingSeconds < this.srvModel.displayExpirationTimer;
|
|
},
|
|
showPaymentDueInfo () {
|
|
return this.isPaidPartial;
|
|
},
|
|
showRecommendedFee () {
|
|
return this.isActive && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
|
|
},
|
|
orderAmount () {
|
|
return this.asNumber(this.srvModel.orderAmount);
|
|
},
|
|
due () {
|
|
return this.asNumber(this.srvModel.due);
|
|
},
|
|
paid () {
|
|
return this.asNumber(this.srvModel.paid);
|
|
},
|
|
pmId() {
|
|
return this.paymentMethodId || this.srvModel.paymentMethodId;
|
|
},
|
|
minutesLeft () {
|
|
return Math.floor(this.remainingSeconds / 60);
|
|
},
|
|
secondsLeft () {
|
|
return Math.floor(this.remainingSeconds % 60);
|
|
},
|
|
timeText () {
|
|
return this.remainingSeconds > 0
|
|
? `${this.padTime(this.minutesLeft)}:${this.padTime(this.secondsLeft)}`
|
|
: '00:00';
|
|
},
|
|
storeLink () {
|
|
return this.srvModel.merchantRefLink && this.srvModel.merchantRefLink !== this.srvModel.receiptLink
|
|
? this.srvModel.merchantRefLink
|
|
: null;
|
|
},
|
|
paymentMethodIds () {
|
|
return this.srvModel.availablePaymentMethods.map(function (c) { return c.paymentMethodId });
|
|
},
|
|
paymentMethodComponent() {
|
|
return this.isPluginPaymentMethod
|
|
? `${this.pmId}Checkout`
|
|
: this.srvModel.activated && this.srvModel.checkoutBodyComponentName;
|
|
},
|
|
isPluginPaymentMethod () {
|
|
return !this.paymentMethodIds.includes(this.pmId);
|
|
},
|
|
realPaymentMethodCurrency () {
|
|
return this.srvModel.paymentMethodCurrency.toLowerCase() === 'sats' ? 'BTC' : this.srvModel.paymentMethodCurrency;
|
|
},
|
|
displayedPaymentMethods: function () {
|
|
return this.srvModel?.availablePaymentMethods?.filter(pm => pm.displayed) ?? [];
|
|
}
|
|
},
|
|
watch: {
|
|
isProcessing: function (newValue, oldValue) {
|
|
if (newValue === true && oldValue === false) {
|
|
// poll from here on
|
|
this.listenForConfirmations();
|
|
// celebration!
|
|
const self = this;
|
|
Vue.nextTick(function () {
|
|
self.celebratePayment(5000);
|
|
});
|
|
}
|
|
},
|
|
isSettled: function (newValue, oldValue) {
|
|
if (newValue === true && oldValue === false) {
|
|
const duration = 5000;
|
|
const self = this;
|
|
// stop polling
|
|
if (this.pollTimeoutID) {
|
|
clearTimeout(this.pollTimeoutID);
|
|
}
|
|
// celebration!
|
|
Vue.nextTick(function () {
|
|
self.celebratePayment(duration);
|
|
});
|
|
// automatic redirect or close
|
|
if (self.srvModel.redirectAutomatically && self.storeLink) {
|
|
setTimeout(function () {
|
|
if (self.isModal && window.top.location === self.storeLink) {
|
|
self.close();
|
|
} else {
|
|
window.top.location = self.storeLink;
|
|
}
|
|
}, duration);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async mounted () {
|
|
this.updateData(this.srvModel);
|
|
this.updateTimer();
|
|
if (this.isActive || this.isProcessing) {
|
|
this.listenIn();
|
|
}
|
|
if (this.isProcessing) {
|
|
this.listenForConfirmations();
|
|
}
|
|
if (this.srvModel.paymentSoundUrl) {
|
|
this.prepareSound(this.srvModel.paymentSoundUrl).then(sound => this.paymentSound = sound);
|
|
this.prepareSound(this.srvModel.nfcReadSoundUrl).then(sound => this.nfcReadSound = sound);
|
|
this.prepareSound(this.srvModel.errorSoundUrl).then(sound => this.errorSound = sound);
|
|
}
|
|
if (this.nfc.supported) {
|
|
await this.setupNFC();
|
|
}
|
|
updateLanguageSelect();
|
|
|
|
window.parent.postMessage('loaded', '*');
|
|
},
|
|
beforeDestroy () {
|
|
if (this.nfc.readerAbortController) {
|
|
this.nfc.readerAbortController.abort()
|
|
}
|
|
},
|
|
methods: {
|
|
asNumber,
|
|
changePaymentMethod (id) { // payment method or plugin id
|
|
if (this.pmId !== id) {
|
|
this.paymentMethodId = id;
|
|
this.fetchData();
|
|
// update url
|
|
const url = new URL(window.location.href);
|
|
url.pathname = checkoutBaseUrl + '/' + id;
|
|
history.pushState({}, "", url);
|
|
}
|
|
},
|
|
changeLanguage (e) {
|
|
updateLanguage(e.target.value);
|
|
},
|
|
padTime (val) {
|
|
return val.toString().padStart(2, '0');
|
|
},
|
|
close () {
|
|
window.parent.postMessage('close', '*');
|
|
},
|
|
updateTimer () {
|
|
this.remainingSeconds = Math.floor((this.endDate.getTime() - new Date().getTime())/1000);
|
|
if (this.isActive) {
|
|
setTimeout(this.updateTimer, 500);
|
|
}
|
|
},
|
|
listenIn () {
|
|
const self = this;
|
|
let socket = null;
|
|
const updateFn = this.fetchData;
|
|
const supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
|
|
if (supportsWebSockets) {
|
|
const protocol = window.location.protocol.replace('http', 'ws');
|
|
const wsUri = `${protocol}//${window.location.host}${statusWsUrl}`;
|
|
try {
|
|
socket = new WebSocket(wsUri);
|
|
socket.onmessage = async function (e) {
|
|
if (e.data !== 'ping') await updateFn();
|
|
};
|
|
socket.onerror = function (e) {
|
|
console.error('Error while connecting to websocket for invoice notifications (callback):', e);
|
|
};
|
|
socket.onclose = function () {
|
|
self.pollUpdates(2000, socket);
|
|
};
|
|
}
|
|
catch (e) {
|
|
console.error('Error while connecting to websocket for invoice notifications', e);
|
|
}
|
|
}
|
|
// fallback in case there is no websocket support
|
|
if (!socket || socket.readyState !== 1) {
|
|
this.pollUpdates(2000, socket)
|
|
}
|
|
},
|
|
listenForConfirmations () {
|
|
this.pollUpdates(30000);
|
|
},
|
|
pollUpdates (interval, socket) {
|
|
const self = this;
|
|
const updateFn = this.fetchData;
|
|
if (self.pollTimeoutID) {
|
|
clearTimeout(self.pollTimeoutID);
|
|
}
|
|
(function pollFn() {
|
|
self.pollTimeoutID = setTimeout(async function () {
|
|
if (!socket || socket.readyState !== 1) {
|
|
await updateFn();
|
|
pollFn();
|
|
}
|
|
}, interval);
|
|
})();
|
|
},
|
|
async fetchData () {
|
|
if (this.isPluginPaymentMethod) return;
|
|
|
|
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.updateData(data);
|
|
}
|
|
},
|
|
updateData (data) {
|
|
if (this.srvModel.status !== data.status) {
|
|
const { invoiceId } = this.srvModel;
|
|
const { status } = data;
|
|
window.parent.postMessage({ invoiceId, status }, '*');
|
|
}
|
|
const newEnd = new Date();
|
|
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
|
|
this.endDate = newEnd;
|
|
|
|
// updating ui
|
|
this.srvModel = data;
|
|
},
|
|
replaceNewlines (value) {
|
|
return value ? value.replace(/\n/ig, '<br>') : '';
|
|
},
|
|
playSound (soundName) {
|
|
const audioBuffer = this[soundName + 'Sound'];
|
|
if (!audioBuffer || !this.audioContext || this.audioContext.state === 'suspended') return;
|
|
const source = this.audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(this.audioContext.destination);
|
|
source.start();
|
|
},
|
|
async celebratePayment (duration) {
|
|
// sound
|
|
this.playSound('payment')
|
|
// confetti
|
|
const $confettiEl = document.getElementById('confetti')
|
|
if (window.confetti && $confettiEl && !$confettiEl.dataset.running) {
|
|
$confettiEl.dataset.running = true;
|
|
await window.confetti($confettiEl, {
|
|
duration,
|
|
spread: 90,
|
|
stagger: 5,
|
|
elementCount: 121,
|
|
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"]
|
|
});
|
|
delete $confettiEl.dataset.running;
|
|
}
|
|
},
|
|
async prepareSound (url) {
|
|
if (!this.audioContext) return;
|
|
const response = await fetch(url)
|
|
if (!response.ok) return console.error(`Could not load payment sound, HTTP error ${response.status}`);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return await this.audioContext.decodeAudioData(arrayBuffer);
|
|
},
|
|
async setupNFC () {
|
|
try {
|
|
this.$set(this.nfc, 'permissionGranted', navigator.permissions && (await navigator.permissions.query({ name: 'nfc' })).state === 'granted');
|
|
} catch (e) {}
|
|
if (this.nfc.permissionGranted) {
|
|
await this.startNFCScan();
|
|
}
|
|
},
|
|
async startNFCScan () {
|
|
if (this.nfc.scanning) return;
|
|
this.$set(this.nfc, 'scanning', true);
|
|
try {
|
|
const inModal = window.self !== window.top;
|
|
const ndef = inModal ? new NDEFReaderWrapper() : new NDEFReader();
|
|
this.nfc.readerAbortController = new AbortController()
|
|
this.nfc.readerAbortController.signal.onabort = () => {
|
|
this.$set(this.nfc, 'scanning', false);
|
|
};
|
|
|
|
await ndef.scan({ signal: this.nfc.readerAbortController.signal })
|
|
ndef.onreadingerror = () => this.handleNFCError('Could not read NFC tag')
|
|
ndef.onreading = async ({ message }) => {
|
|
const record = message.records[0]
|
|
if (record && record.data) {
|
|
const textDecoder = new TextDecoder('utf-8')
|
|
const decoded = textDecoder.decode(record.data)
|
|
this.$emit('read-nfc-data', decoded)
|
|
} else {
|
|
this.handleNFCError('Could not read NFC tag: No data')
|
|
}
|
|
}
|
|
|
|
if (inModal) {
|
|
// receive messages from iframe
|
|
window.addEventListener('message', async event => {
|
|
// deny messages from other origins
|
|
if (event.origin !== window.location.origin) return
|
|
|
|
const { action, data } = event.data
|
|
switch (action) {
|
|
case 'nfc:data':
|
|
this.$emit('read-nfc-data', data)
|
|
break;
|
|
case 'nfc:error':
|
|
this.handleNFCError('Could not read NFC tag')
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
// we came here, so the user must have allowed NFC access
|
|
this.$set(this.nfc, 'permissionGranted', true);
|
|
} catch (error) {
|
|
this.handleNFCError(`NFC scan failed: ${error}`);
|
|
}
|
|
},
|
|
handleNFCData() { // child component reports it is handling the data
|
|
this.playSound('nfcRead');
|
|
this.$set(this.nfc, 'submitting', true);
|
|
this.$set(this.nfc, 'errorMessage', null);
|
|
this.$set(this.nfc, 'warningMessage', null);
|
|
},
|
|
handleNFCResult(message) { // child component reports result for handling the data
|
|
this.$set(this.nfc, 'submitting', false);
|
|
if (message) {
|
|
this.$set(this.nfc, 'warningMessage', message);
|
|
}
|
|
},
|
|
handleNFCError(message) {
|
|
// internal or via child component reporting failure of handling the data
|
|
this.playSound('error');
|
|
this.$set(this.nfc, 'submitting', false);
|
|
this.$set(this.nfc, 'errorMessage', message);
|
|
const $nfc = document.getElementById('NFC');
|
|
if ($nfc) {
|
|
$nfc.scrollIntoView({ block: 'end', inline: 'center', behavior: 'smooth' });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
i18next
|
|
.use(window.i18nextHttpBackend)
|
|
.init({
|
|
backend: {
|
|
loadPath: i18nUrl
|
|
},
|
|
lng: startingLanguage,
|
|
fallbackLng: fallbackLanguage,
|
|
nsSeparator: false,
|
|
keySeparator: false,
|
|
load: 'currentOnly'
|
|
}, initApp);
|