feat: BTCPay Server payment integration via pow.dgray.io proxy
This commit is contained in:
@@ -4,6 +4,7 @@ import { auth } from '../../services/auth.js'
|
||||
import { directus } from '../../services/directus.js'
|
||||
import { categoriesService } from '../../services/categories.js'
|
||||
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
|
||||
import { createInvoice, openCheckout, pollUntilDone, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js'
|
||||
import { escapeHTML } from '../../utils/helpers.js'
|
||||
import '../location-picker.js'
|
||||
import '../pow-captcha.js'
|
||||
@@ -527,7 +528,6 @@ class PageCreate extends HTMLElement {
|
||||
|
||||
const form = e.target
|
||||
|
||||
// Read current form values directly (more reliable than event listeners)
|
||||
const formElements = {
|
||||
title: form.querySelector('#title')?.value || '',
|
||||
description: form.querySelector('#description')?.value || '',
|
||||
@@ -541,7 +541,6 @@ class PageCreate extends HTMLElement {
|
||||
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
|
||||
}
|
||||
|
||||
// Validate PoW Captcha (only for new accounts and new listings)
|
||||
if (!this.editMode && this.isNewAccount) {
|
||||
const captcha = this.querySelector('#pow-captcha')
|
||||
if (!captcha?.isSolved()) {
|
||||
@@ -550,7 +549,6 @@ class PageCreate extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Monero address
|
||||
if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
|
||||
this.showError(t('create.invalidMoneroAddress'))
|
||||
return
|
||||
@@ -564,14 +562,12 @@ class PageCreate extends HTMLElement {
|
||||
submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
|
||||
|
||||
try {
|
||||
// Upload new images first
|
||||
let newImageIds = []
|
||||
if (this.imageFiles.length > 0) {
|
||||
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
|
||||
newImageIds = uploadedFiles.map(f => f.id)
|
||||
}
|
||||
|
||||
// Build listing data from form values
|
||||
const listingData = {
|
||||
title: formElements.title,
|
||||
slug: this.generateSlug(formElements.title),
|
||||
@@ -580,12 +576,6 @@ class PageCreate extends HTMLElement {
|
||||
currency: formElements.currency
|
||||
}
|
||||
|
||||
// Only set status on create, not on edit
|
||||
if (!this.editMode) {
|
||||
listingData.status = 'published'
|
||||
}
|
||||
|
||||
// Add optional fields only if set
|
||||
if (formElements.price_mode) listingData.price_mode = formElements.price_mode
|
||||
if (formElements.category) listingData.category = formElements.category
|
||||
if (formElements.condition) listingData.condition = formElements.condition
|
||||
@@ -594,16 +584,7 @@ class PageCreate extends HTMLElement {
|
||||
listingData.shipping_cost = parseFloat(formElements.shipping_cost)
|
||||
}
|
||||
if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
|
||||
|
||||
// Calculate expires_at (only on create) - 30 days for regular users, 60 for power users
|
||||
if (!this.editMode) {
|
||||
const days = 30 // TODO: 60 for power users
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + days)
|
||||
listingData.expires_at = expiresAt.toISOString()
|
||||
}
|
||||
|
||||
// Handle location - find or create in locations collection
|
||||
if (this.formData.locationData) {
|
||||
const locationId = await this.findOrCreateLocation(this.formData.locationData)
|
||||
if (locationId) {
|
||||
@@ -612,8 +593,6 @@ class PageCreate extends HTMLElement {
|
||||
}
|
||||
|
||||
if (this.editMode) {
|
||||
// Update existing listing
|
||||
// Add new images if any
|
||||
if (newImageIds.length > 0) {
|
||||
listingData.images = {
|
||||
create: newImageIds.map((id, index) => ({
|
||||
@@ -626,7 +605,10 @@ class PageCreate extends HTMLElement {
|
||||
await directus.updateListing(this.editId, listingData)
|
||||
router.navigate(`/listing/${this.editId}`)
|
||||
} else {
|
||||
// Create new listing
|
||||
// Save as draft first, then trigger payment
|
||||
listingData.status = 'draft'
|
||||
listingData.payment_status = 'unpaid'
|
||||
|
||||
if (newImageIds.length > 0) {
|
||||
listingData.images = {
|
||||
create: newImageIds.map((id, index) => ({
|
||||
@@ -638,9 +620,9 @@ class PageCreate extends HTMLElement {
|
||||
|
||||
const listing = await directus.createListing(listingData)
|
||||
this.clearDraft()
|
||||
|
||||
|
||||
if (listing?.id) {
|
||||
router.navigate(`/listing/${listing.id}`)
|
||||
await this.startPayment(listing.id, formElements.currency)
|
||||
} else {
|
||||
router.navigate('/')
|
||||
}
|
||||
@@ -652,12 +634,100 @@ class PageCreate extends HTMLElement {
|
||||
submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
|
||||
this.submitting = false
|
||||
|
||||
// Extract detailed error message
|
||||
const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed')
|
||||
this.showError(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
async startPayment(listingId, currency = 'EUR') {
|
||||
const submitBtn = this.querySelector('[type="submit"]')
|
||||
|
||||
try {
|
||||
// Check for existing pending invoice
|
||||
const pending = getPendingInvoice(listingId)
|
||||
let invoiceId = null
|
||||
|
||||
if (pending?.invoiceId) {
|
||||
const status = await getInvoiceStatus(pending.invoiceId)
|
||||
if (status.status === 'New') {
|
||||
invoiceId = pending.invoiceId
|
||||
} else if (status.status === 'Settled') {
|
||||
await this.onPaymentSuccess(listingId)
|
||||
return
|
||||
} else {
|
||||
clearPendingInvoice(listingId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!invoiceId) {
|
||||
if (submitBtn) submitBtn.textContent = t('payment.paying')
|
||||
const invoice = await createInvoice(listingId, currency)
|
||||
invoiceId = invoice.invoiceId
|
||||
savePendingInvoice(listingId, invoiceId)
|
||||
|
||||
await directus.updateListing(listingId, {
|
||||
payment_status: 'pending',
|
||||
btcpay_invoice_id: invoiceId
|
||||
})
|
||||
}
|
||||
|
||||
const modalStatus = await openCheckout(invoiceId)
|
||||
|
||||
if (modalStatus === 'complete' || modalStatus === 'paid') {
|
||||
await this.onPaymentSuccess(listingId)
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for final status after modal close
|
||||
const result = await pollUntilDone(invoiceId, {
|
||||
interval: 4000,
|
||||
timeout: 60000,
|
||||
onUpdate: (update) => {
|
||||
if (update.status === 'Processing' && submitBtn) {
|
||||
submitBtn.textContent = t('payment.processing')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (result.status === 'Settled') {
|
||||
await this.onPaymentSuccess(listingId)
|
||||
} else {
|
||||
await directus.updateListing(listingId, { payment_status: 'expired' })
|
||||
clearPendingInvoice(listingId)
|
||||
this.showError(t('payment.expired'))
|
||||
this.submitting = false
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = t('create.publish')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment failed:', error)
|
||||
this.showError(t('payment.failed'))
|
||||
this.submitting = false
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = t('create.publish')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onPaymentSuccess(listingId) {
|
||||
const days = 30
|
||||
const expiresAt = new Date()
|
||||
expiresAt.setDate(expiresAt.getDate() + days)
|
||||
|
||||
await directus.updateListing(listingId, {
|
||||
status: 'published',
|
||||
payment_status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
expires_at: expiresAt.toISOString()
|
||||
})
|
||||
|
||||
clearPendingInvoice(listingId)
|
||||
router.navigate(`/listing/${listingId}`)
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
let errorDiv = this.querySelector('.form-error')
|
||||
if (!errorDiv) {
|
||||
|
||||
170
js/services/btcpay.js
Normal file
170
js/services/btcpay.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const POW_SERVER = 'https://pow.dgray.io'
|
||||
|
||||
let modalScriptLoaded = false
|
||||
let modalScriptLoading = null
|
||||
|
||||
/**
|
||||
* Load BTCPay modal checkout script once
|
||||
*/
|
||||
async function ensureModalLoaded() {
|
||||
if (modalScriptLoaded) return
|
||||
if (modalScriptLoading) return modalScriptLoading
|
||||
|
||||
modalScriptLoading = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://pay.xmr.rocks/modal/btcpay.js'
|
||||
script.onload = () => {
|
||||
modalScriptLoaded = true
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => reject(new Error('Failed to load BTCPay checkout'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return modalScriptLoading
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment invoice for a listing
|
||||
* @param {string} listingId - The listing UUID
|
||||
* @param {string} [currency='EUR'] - Payment currency
|
||||
* @returns {Promise<Object>} { invoiceId, checkoutLink, status, expirationTime }
|
||||
*/
|
||||
export async function createInvoice(listingId, currency = 'EUR') {
|
||||
const response = await fetch(`${POW_SERVER}/btcpay/invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ listingId, currency })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to create invoice')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check invoice payment status
|
||||
* @param {string} invoiceId
|
||||
* @returns {Promise<Object>} { invoiceId, status, additionalStatus }
|
||||
*/
|
||||
export async function getInvoiceStatus(invoiceId) {
|
||||
const response = await fetch(`${POW_SERVER}/btcpay/status?id=${encodeURIComponent(invoiceId)}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to check invoice status')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the BTCPay modal checkout
|
||||
* @param {string} invoiceId
|
||||
* @returns {Promise<string>} Final status when modal closes
|
||||
*/
|
||||
export async function openCheckout(invoiceId) {
|
||||
await ensureModalLoaded()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let lastStatus = null
|
||||
|
||||
window.btcpay.onModalReceiveMessage((msg) => {
|
||||
if (msg?.status) lastStatus = msg.status
|
||||
})
|
||||
|
||||
window.btcpay.onModalWillLeave(() => {
|
||||
resolve(lastStatus || 'unknown')
|
||||
})
|
||||
|
||||
window.btcpay.showInvoice(invoiceId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll invoice status until settled, expired, or timeout
|
||||
* @param {string} invoiceId
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.interval=4000] - Poll interval in ms
|
||||
* @param {number} [options.timeout=900000] - Timeout in ms (default 15min)
|
||||
* @param {Function} [options.onUpdate] - Callback on status change
|
||||
* @returns {Promise<Object>} Final status object
|
||||
*/
|
||||
export async function pollUntilDone(invoiceId, options = {}) {
|
||||
const {
|
||||
interval = 4000,
|
||||
timeout = 900000,
|
||||
onUpdate = null
|
||||
} = options
|
||||
|
||||
const startTime = Date.now()
|
||||
let lastStatus = null
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await getInvoiceStatus(invoiceId)
|
||||
|
||||
if (result.status !== lastStatus) {
|
||||
lastStatus = result.status
|
||||
if (onUpdate) onUpdate(result)
|
||||
}
|
||||
|
||||
if (['Settled', 'Expired', 'Invalid'].includes(result.status)) {
|
||||
return result
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, interval))
|
||||
}
|
||||
|
||||
throw new Error('Payment check timed out')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a listing has a pending invoice stored locally
|
||||
* @param {string} listingId
|
||||
* @returns {Object|null} { invoiceId, createdAt }
|
||||
*/
|
||||
export function getPendingInvoice(listingId) {
|
||||
try {
|
||||
const data = localStorage.getItem(`dgray_invoice_${listingId}`)
|
||||
return data ? JSON.parse(data) : null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a pending invoice reference locally
|
||||
* @param {string} listingId
|
||||
* @param {string} invoiceId
|
||||
*/
|
||||
export function savePendingInvoice(listingId, invoiceId) {
|
||||
try {
|
||||
localStorage.setItem(`dgray_invoice_${listingId}`, JSON.stringify({
|
||||
invoiceId,
|
||||
createdAt: Date.now()
|
||||
}))
|
||||
} catch (e) {
|
||||
// Storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove pending invoice reference
|
||||
* @param {string} listingId
|
||||
*/
|
||||
export function clearPendingInvoice(listingId) {
|
||||
localStorage.removeItem(`dgray_invoice_${listingId}`)
|
||||
}
|
||||
|
||||
export default {
|
||||
createInvoice,
|
||||
getInvoiceStatus,
|
||||
openCheckout,
|
||||
pollUntilDone,
|
||||
getPendingInvoice,
|
||||
savePendingInvoice,
|
||||
clearPendingInvoice
|
||||
}
|
||||
Reference in New Issue
Block a user