1006 lines
36 KiB
JavaScript
1006 lines
36 KiB
JavaScript
import { t, i18n } from '../../i18n.js'
|
|
import { router } from '../../router.js'
|
|
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, getPendingInvoice, savePendingInvoice, clearPendingInvoice, getInvoiceStatus } from '../../services/btcpay.js'
|
|
import { escapeHTML } from '../../utils/helpers.js'
|
|
import '../location-picker.js'
|
|
import '../pow-captcha.js'
|
|
import '../image-cropper.js'
|
|
|
|
const STORAGE_KEY = 'dgray_create_draft'
|
|
|
|
class PageCreate extends HTMLElement {
|
|
constructor() {
|
|
super()
|
|
this.editMode = false
|
|
this.editId = null
|
|
this.existingImages = []
|
|
this.formData = this.loadDraft() || this.getEmptyFormData()
|
|
this.imageFiles = []
|
|
this.imagePreviews = []
|
|
this.categories = []
|
|
this.submitting = false
|
|
this.isNewAccount = true
|
|
}
|
|
|
|
getEmptyFormData() {
|
|
// Use user's preferred currency from settings as default
|
|
const defaultCurrency = getDisplayCurrency()
|
|
// Map display currencies to listing currencies (XMR not for fiat listings)
|
|
const currency = ['EUR', 'USD', 'CHF'].includes(defaultCurrency) ? defaultCurrency : 'EUR'
|
|
|
|
return {
|
|
title: '',
|
|
description: '',
|
|
price: '',
|
|
currency,
|
|
price_mode: 'fiat',
|
|
price_type: 'fixed',
|
|
category: '',
|
|
condition: 'good',
|
|
location: '',
|
|
shipping: false,
|
|
shipping_cost: '',
|
|
moneroAddress: ''
|
|
}
|
|
}
|
|
|
|
loadDraft() {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY)
|
|
return saved ? JSON.parse(saved) : null
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
saveDraft() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.formData))
|
|
} catch (e) {
|
|
// Storage full or unavailable
|
|
}
|
|
}
|
|
|
|
clearDraft() {
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
}
|
|
|
|
async connectedCallback() {
|
|
// Check if logged in
|
|
if (!auth.isLoggedIn()) {
|
|
this.showLoginRequired()
|
|
return
|
|
}
|
|
|
|
// Check if edit mode
|
|
if (this.dataset.id) {
|
|
this.editMode = true
|
|
this.editId = this.dataset.id
|
|
await this.loadExistingListing()
|
|
} else {
|
|
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
|
|
}
|
|
|
|
await this.loadCategories()
|
|
await this.checkAccountStatus()
|
|
this.render()
|
|
this.unsubscribe = i18n.subscribe(() => this.render())
|
|
}
|
|
|
|
async loadExistingListing() {
|
|
try {
|
|
const listing = await directus.getListing(this.editId)
|
|
|
|
// Verify ownership
|
|
const user = await auth.getUser()
|
|
if (listing.user_created !== user?.id) {
|
|
window.location.hash = '#/'
|
|
return
|
|
}
|
|
|
|
this.formData = {
|
|
title: listing.title || '',
|
|
description: listing.description || '',
|
|
price: listing.price?.toString() || '',
|
|
currency: listing.currency || 'EUR',
|
|
price_mode: listing.price_mode || 'fiat',
|
|
price_type: listing.price_type || 'fixed',
|
|
category: listing.category?.id || listing.category || '',
|
|
condition: listing.condition || 'good',
|
|
location: listing.location?.id || listing.location || '',
|
|
shipping: listing.shipping || false,
|
|
shipping_cost: listing.shipping_cost?.toString() || '',
|
|
moneroAddress: listing.monero_address || '',
|
|
status: listing.status || 'published'
|
|
}
|
|
|
|
// Store existing images
|
|
this.existingImages = (listing.images || []).map(img => ({
|
|
id: img.id,
|
|
fileId: img.directus_files_id?.id || img.directus_files_id
|
|
}))
|
|
|
|
// Create previews for existing images
|
|
this.imagePreviews = this.existingImages.map(img =>
|
|
directus.getThumbnailUrl(img.fileId, 200)
|
|
)
|
|
} catch (e) {
|
|
console.error('Failed to load listing for edit:', e)
|
|
window.location.hash = '#/'
|
|
}
|
|
}
|
|
|
|
async checkAccountStatus() {
|
|
try {
|
|
// Check if user has any published listings
|
|
const user = await auth.getUser()
|
|
if (user?.id) {
|
|
const listings = await directus.get('/items/listings', {
|
|
filter: { user_created: { _eq: user.id } },
|
|
limit: 1
|
|
})
|
|
this.isNewAccount = !listings.data || listings.data.length === 0
|
|
}
|
|
} catch (e) {
|
|
// Default to new account (show captcha)
|
|
this.isNewAccount = true
|
|
}
|
|
}
|
|
|
|
showLoginRequired() {
|
|
// Show login modal directly
|
|
const authModal = document.querySelector('auth-modal')
|
|
if (authModal) {
|
|
authModal.show('login')
|
|
authModal.addEventListener('login', async () => {
|
|
// After login, load the create page
|
|
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
|
|
await this.loadCategories()
|
|
this.render()
|
|
this.unsubscribe = i18n.subscribe(() => this.render())
|
|
}, { once: true })
|
|
authModal.addEventListener('close', () => {
|
|
// If closed without login, go back
|
|
if (!auth.isLoggedIn()) {
|
|
router.back()
|
|
}
|
|
}, { once: true })
|
|
}
|
|
|
|
// Show minimal loading state while modal is open
|
|
this.innerHTML = /* html */`
|
|
<div class="create-page">
|
|
<div class="login-required">
|
|
<p>${t('auth.loginRequired')}</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
async loadCategories() {
|
|
try {
|
|
this.categories = await categoriesService.getAll()
|
|
} catch (e) {
|
|
console.error('Failed to load categories:', e)
|
|
this.categories = []
|
|
}
|
|
}
|
|
|
|
validateMoneroAddress(address) {
|
|
if (!address) return false
|
|
// Standard addresses: start with 4, 95 chars
|
|
// Subaddresses: start with 8, 95 chars
|
|
// Integrated addresses: start with 4, 106 chars
|
|
const standardRegex = /^4[0-9A-Za-z]{94}$/
|
|
const subaddressRegex = /^8[0-9A-Za-z]{94}$/
|
|
const integratedRegex = /^4[0-9A-Za-z]{105}$/
|
|
return standardRegex.test(address) || subaddressRegex.test(address) || integratedRegex.test(address)
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.unsubscribe) this.unsubscribe()
|
|
}
|
|
|
|
render() {
|
|
const pageTitle = this.editMode ? t('create.editTitle') : t('create.title')
|
|
|
|
this.innerHTML = /* html */`
|
|
<div class="create-page">
|
|
<h1>${pageTitle}</h1>
|
|
|
|
${!this.editMode && this.hasDraft ? `
|
|
<div class="draft-notice">
|
|
<span>${t('create.draftRestored')}</span>
|
|
<button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button>
|
|
</div>
|
|
` : ''}
|
|
|
|
<form id="create-form" class="create-form">
|
|
<div class="form-group">
|
|
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
|
|
<input
|
|
type="text"
|
|
class="input"
|
|
id="title"
|
|
name="title"
|
|
value="${escapeHTML(this.formData.title)}"
|
|
required
|
|
data-i18n-placeholder="create.titlePlaceholder"
|
|
placeholder="${t('create.titlePlaceholder')}"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="category">${t('create.category')}</label>
|
|
<select class="input" id="category" name="category" required>
|
|
<option value="">${t('create.selectCategory')}</option>
|
|
${this.categories.map(cat => `
|
|
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
|
|
${t(`categories.${cat.slug}`) || cat.name}
|
|
</option>
|
|
`).join('')}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="condition">${t('create.condition')}</label>
|
|
<select class="input" id="condition" name="condition" required>
|
|
<option value="new" ${this.formData.condition === 'new' ? 'selected' : ''}>${t('create.conditionNew')}</option>
|
|
<option value="like_new" ${this.formData.condition === 'like_new' ? 'selected' : ''}>${t('create.conditionLikeNew')}</option>
|
|
<option value="good" ${this.formData.condition === 'good' ? 'selected' : ''}>${t('create.conditionGood')}</option>
|
|
<option value="fair" ${this.formData.condition === 'fair' ? 'selected' : ''}>${t('create.conditionFair')}</option>
|
|
<option value="poor" ${this.formData.condition === 'poor' ? 'selected' : ''}>${t('create.conditionPoor')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group form-group-price">
|
|
<label class="label" for="price">${t('create.price')}</label>
|
|
<input
|
|
type="number"
|
|
class="input"
|
|
id="price"
|
|
name="price"
|
|
value="${this.formData.price}"
|
|
min="0"
|
|
step="0.0001"
|
|
required
|
|
placeholder="0.00"
|
|
>
|
|
</div>
|
|
<div class="form-group form-group-currency">
|
|
<label class="label" for="currency">${t('create.currency')}</label>
|
|
<select class="input" id="currency" name="currency">
|
|
${SUPPORTED_CURRENCIES.map(cur => `
|
|
<option value="${cur}" ${this.formData.currency === cur ? 'selected' : ''}>${cur}</option>
|
|
`).join('')}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="price_mode">${t('create.priceMode')}</label>
|
|
<select class="input" id="price_mode" name="price_mode">
|
|
<option value="fiat" ${this.formData.price_mode === 'fiat' ? 'selected' : ''}>${t('create.priceModeFiat')}</option>
|
|
<option value="xmr" ${this.formData.price_mode === 'xmr' ? 'selected' : ''}>${t('create.priceModeXmr')}</option>
|
|
</select>
|
|
<p class="field-hint">${t('create.priceModeHint')}</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="shipping" name="shipping" ${this.formData.shipping ? 'checked' : ''}>
|
|
<span>${t('create.shippingAvailable')}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group shipping-cost-group" id="shipping-cost-group" style="display: ${this.formData.shipping ? 'block' : 'none'}">
|
|
<label class="label" for="shipping_cost">${t('create.shippingCost')}</label>
|
|
<input
|
|
type="number"
|
|
class="input"
|
|
id="shipping_cost"
|
|
name="shipping_cost"
|
|
min="0"
|
|
step="0.01"
|
|
value="${this.formData.shipping_cost || ''}"
|
|
placeholder="${t('create.shippingCostPlaceholder')}"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
|
|
<location-picker
|
|
id="location-picker"
|
|
placeholder="${t('create.locationPlaceholder')}"
|
|
></location-picker>
|
|
<p class="field-hint">${t('create.locationHint') || 'Stadt oder PLZ eingeben'}</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="description" data-i18n="create.description">${t('create.description')}</label>
|
|
<textarea
|
|
class="input"
|
|
id="description"
|
|
name="description"
|
|
rows="5"
|
|
required
|
|
data-i18n-placeholder="create.descriptionPlaceholder"
|
|
placeholder="${t('create.descriptionPlaceholder')}"
|
|
>${escapeHTML(this.formData.description)}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" data-i18n="create.images">${t('create.images')}</label>
|
|
<div class="image-upload">
|
|
<input type="file" id="images" name="images" accept="image/*" multiple hidden>
|
|
<label for="images" class="upload-area" id="upload-area">
|
|
<span class="upload-icon">📷</span>
|
|
<span data-i18n="create.uploadImages">${t('create.uploadImages')}</span>
|
|
</label>
|
|
<div class="image-previews" id="image-previews">
|
|
${this.renderImagePreviews()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
|
|
<input
|
|
type="text"
|
|
class="input"
|
|
id="moneroAddress"
|
|
name="moneroAddress"
|
|
value="${escapeHTML(this.formData.moneroAddress)}"
|
|
required
|
|
placeholder="${t('create.moneroPlaceholder')}"
|
|
>
|
|
<p class="field-hint">${t('create.moneroHint')}</p>
|
|
</div>
|
|
|
|
${this.isNewAccount ? `
|
|
<div class="form-group">
|
|
<pow-captcha id="pow-captcha"></pow-captcha>
|
|
</div>
|
|
` : ''}
|
|
|
|
<image-cropper id="image-cropper"></image-cropper>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
|
|
${t('create.cancel')}
|
|
</button>
|
|
<button type="submit" class="btn btn-primary btn-lg">
|
|
${this.editMode ? t('create.saveChanges') : t('create.publish')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
`
|
|
|
|
this.setupEventListeners()
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const form = this.querySelector('#create-form')
|
|
const cancelBtn = this.querySelector('#cancel-btn')
|
|
const imageInput = this.querySelector('#images')
|
|
|
|
form.addEventListener('submit', (e) => this.handleSubmit(e))
|
|
cancelBtn.addEventListener('click', () => router.back())
|
|
|
|
form.querySelectorAll('input:not([type="checkbox"]), textarea, select').forEach(input => {
|
|
input.addEventListener('input', (e) => {
|
|
if (e.target.name) {
|
|
this.formData[e.target.name] = e.target.value
|
|
this.saveDraft()
|
|
}
|
|
})
|
|
})
|
|
|
|
// Checkbox handler with shipping cost toggle
|
|
const shippingCheckbox = this.querySelector('#shipping')
|
|
const shippingCostGroup = this.querySelector('#shipping-cost-group')
|
|
shippingCheckbox?.addEventListener('change', (e) => {
|
|
this.formData.shipping = e.target.checked
|
|
if (shippingCostGroup) {
|
|
shippingCostGroup.style.display = e.target.checked ? 'block' : 'none'
|
|
}
|
|
this.saveDraft()
|
|
})
|
|
|
|
// Location picker handler
|
|
const locationPicker = this.querySelector('#location-picker')
|
|
locationPicker?.addEventListener('location-select', (e) => {
|
|
this.formData.locationData = e.detail
|
|
this.formData.location = e.detail.name
|
|
this.saveDraft()
|
|
})
|
|
|
|
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
|
|
|
|
// Clear draft button
|
|
this.querySelector('#clear-draft-btn')?.addEventListener('click', () => {
|
|
this.clearDraft()
|
|
this.formData = {
|
|
title: '',
|
|
description: '',
|
|
price: '',
|
|
currency: 'EUR',
|
|
price_mode: 'fiat',
|
|
price_type: 'fixed',
|
|
category: '',
|
|
condition: 'good',
|
|
location: '',
|
|
shipping: false,
|
|
moneroAddress: ''
|
|
}
|
|
this.hasDraft = false
|
|
this.render()
|
|
this.setupEventListeners()
|
|
})
|
|
}
|
|
|
|
handleImageSelect(e) {
|
|
const files = Array.from(e.target.files)
|
|
this.pendingFiles = [...files]
|
|
this.processNextImage()
|
|
}
|
|
|
|
processNextImage() {
|
|
if (this.pendingFiles.length === 0) return
|
|
if (this.imageFiles.length >= 5) {
|
|
this.pendingFiles = []
|
|
return
|
|
}
|
|
|
|
const file = this.pendingFiles.shift()
|
|
const cropper = this.querySelector('#image-cropper')
|
|
|
|
if (cropper) {
|
|
cropper.open(
|
|
file,
|
|
(croppedFile, previewUrl) => {
|
|
// On crop complete
|
|
this.imageFiles.push(croppedFile)
|
|
this.imagePreviews.push(previewUrl)
|
|
this.updateImagePreviews()
|
|
// Process next image
|
|
this.processNextImage()
|
|
},
|
|
() => {
|
|
// On cancel - skip this image
|
|
this.processNextImage()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
updateImagePreviews() {
|
|
const container = this.querySelector('#image-previews')
|
|
const uploadArea = this.querySelector('#upload-area')
|
|
|
|
if (container) {
|
|
container.innerHTML = this.renderImagePreviews()
|
|
this.setupRemoveListeners()
|
|
}
|
|
|
|
if (uploadArea) {
|
|
uploadArea.style.display = this.imageFiles.length >= 5 ? 'none' : 'flex'
|
|
}
|
|
}
|
|
|
|
renderImagePreviews() {
|
|
if (this.imagePreviews.length === 0) return ''
|
|
|
|
return this.imagePreviews.map((src, index) => /* html */`
|
|
<div class="image-preview">
|
|
<img src="${src}" alt="Preview ${index + 1}">
|
|
<button type="button" class="remove-image" data-index="${index}" aria-label="${t('common.remove')}">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`).join('')
|
|
}
|
|
|
|
setupRemoveListeners() {
|
|
this.querySelectorAll('.remove-image').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const index = parseInt(e.currentTarget.dataset.index)
|
|
this.imageFiles.splice(index, 1)
|
|
this.imagePreviews.splice(index, 1)
|
|
this.updateImagePreviews()
|
|
})
|
|
})
|
|
}
|
|
|
|
async handleSubmit(e) {
|
|
e.preventDefault()
|
|
|
|
if (this.submitting) return
|
|
|
|
const form = e.target
|
|
|
|
const formElements = {
|
|
title: form.querySelector('#title')?.value || '',
|
|
description: form.querySelector('#description')?.value || '',
|
|
price: form.querySelector('#price')?.value || '',
|
|
currency: form.querySelector('#currency')?.value || 'EUR',
|
|
price_mode: form.querySelector('#price_mode')?.value || 'fiat',
|
|
category: form.querySelector('#category')?.value || '',
|
|
condition: form.querySelector('#condition')?.value || 'good',
|
|
shipping: form.querySelector('#shipping')?.checked || false,
|
|
shipping_cost: form.querySelector('#shipping_cost')?.value || '',
|
|
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
|
|
}
|
|
|
|
if (!this.editMode && this.isNewAccount) {
|
|
const captcha = this.querySelector('#pow-captcha')
|
|
if (!captcha?.isSolved()) {
|
|
this.showError(t('captcha.error'))
|
|
return
|
|
}
|
|
}
|
|
|
|
if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
|
|
this.showError(t('create.invalidMoneroAddress'))
|
|
return
|
|
}
|
|
|
|
this.submitting = true
|
|
this.clearError()
|
|
|
|
const submitBtn = form.querySelector('[type="submit"]')
|
|
submitBtn.disabled = true
|
|
submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
|
|
|
|
try {
|
|
let newImageIds = []
|
|
if (this.imageFiles.length > 0) {
|
|
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
|
|
newImageIds = uploadedFiles.map(f => f.id)
|
|
}
|
|
|
|
const listingData = {
|
|
title: formElements.title,
|
|
slug: this.generateSlug(formElements.title),
|
|
description: formElements.description,
|
|
price: String(parseFloat(formElements.price) || 0),
|
|
currency: formElements.currency
|
|
}
|
|
|
|
if (formElements.price_mode) listingData.price_mode = formElements.price_mode
|
|
if (formElements.category) listingData.category = formElements.category
|
|
if (formElements.condition) listingData.condition = formElements.condition
|
|
listingData.shipping = formElements.shipping
|
|
if (formElements.shipping && formElements.shipping_cost) {
|
|
listingData.shipping_cost = parseFloat(formElements.shipping_cost)
|
|
}
|
|
if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
|
|
|
|
if (this.formData.locationData) {
|
|
const locationId = await this.findOrCreateLocation(this.formData.locationData)
|
|
if (locationId) {
|
|
listingData.location = locationId
|
|
}
|
|
}
|
|
|
|
if (this.editMode) {
|
|
if (newImageIds.length > 0) {
|
|
listingData.images = {
|
|
create: newImageIds.map((id, index) => ({
|
|
directus_files_id: id,
|
|
sort: this.existingImages.length + index
|
|
}))
|
|
}
|
|
}
|
|
|
|
await directus.updateListing(this.editId, listingData)
|
|
router.navigate(`/listing/${this.editId}`)
|
|
} else {
|
|
// 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) => ({
|
|
directus_files_id: id,
|
|
sort: index
|
|
}))
|
|
}
|
|
}
|
|
|
|
const listing = await directus.createListing(listingData)
|
|
this.clearDraft()
|
|
|
|
if (listing?.id) {
|
|
await this.startPayment(listing.id, formElements.currency)
|
|
} else {
|
|
router.navigate('/')
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save listing:', error)
|
|
console.error('Error details:', JSON.stringify(error.data, null, 2))
|
|
submitBtn.disabled = false
|
|
submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
|
|
this.submitting = false
|
|
|
|
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
|
|
}
|
|
|
|
// Check status once after modal close
|
|
const currentStatus = await getInvoiceStatus(invoiceId)
|
|
|
|
if (currentStatus.status === 'Settled') {
|
|
await this.onPaymentSuccess(listingId)
|
|
return
|
|
}
|
|
|
|
// Processing = payment received, waiting for confirmation
|
|
if (currentStatus.status === 'Processing') {
|
|
await this.onPaymentReceived(listingId)
|
|
return
|
|
}
|
|
|
|
if (currentStatus.status === 'Expired' || currentStatus.status === 'Invalid') {
|
|
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')
|
|
}
|
|
return
|
|
}
|
|
|
|
// Still "New" - user closed modal without paying
|
|
this.showError(t('payment.failed'))
|
|
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) {
|
|
try {
|
|
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()
|
|
})
|
|
} catch (e) {
|
|
console.warn('Could not update listing status, webhook will handle it:', e)
|
|
}
|
|
|
|
clearPendingInvoice(listingId)
|
|
router.navigate('/my-listings')
|
|
}
|
|
|
|
async onPaymentReceived(listingId) {
|
|
try {
|
|
await directus.updateListing(listingId, {
|
|
payment_status: 'processing'
|
|
})
|
|
} catch (e) {
|
|
console.warn('Could not update listing status, webhook will handle it:', e)
|
|
}
|
|
|
|
clearPendingInvoice(listingId)
|
|
router.navigate('/my-listings')
|
|
}
|
|
|
|
showError(message) {
|
|
let errorDiv = this.querySelector('.form-error')
|
|
if (!errorDiv) {
|
|
errorDiv = document.createElement('div')
|
|
errorDiv.className = 'form-error'
|
|
this.querySelector('.form-actions')?.insertAdjacentElement('beforebegin', errorDiv)
|
|
}
|
|
errorDiv.textContent = message
|
|
}
|
|
|
|
clearError() {
|
|
this.querySelector('.form-error')?.remove()
|
|
}
|
|
|
|
generateSlug(title) {
|
|
return title
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.substring(0, 100)
|
|
}
|
|
|
|
async findOrCreateLocation(locationData) {
|
|
if (!locationData || !locationData.name) return null
|
|
|
|
try {
|
|
// Build filter based on available data
|
|
const filter = {}
|
|
|
|
if (locationData.postalCode) {
|
|
filter.postal_code = { _eq: locationData.postalCode }
|
|
}
|
|
if (locationData.countryCode) {
|
|
filter.country = { _eq: locationData.countryCode }
|
|
}
|
|
if (locationData.name) {
|
|
filter.name = { _eq: locationData.name }
|
|
}
|
|
|
|
// Only search if we have at least one filter
|
|
if (Object.keys(filter).length > 0) {
|
|
const existing = await directus.get('/items/locations', {
|
|
filter,
|
|
limit: 1
|
|
})
|
|
|
|
if (existing.data && existing.data.length > 0) {
|
|
return existing.data[0].id
|
|
}
|
|
}
|
|
|
|
// Create new location
|
|
const newLocation = await directus.post('/items/locations', {
|
|
name: locationData.name || '',
|
|
postal_code: locationData.postalCode || null,
|
|
region: locationData.region || null,
|
|
country: locationData.countryCode || null
|
|
})
|
|
|
|
return newLocation.data?.id
|
|
} catch (error) {
|
|
console.error('Failed to find/create location:', error)
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('page-create', PageCreate)
|
|
|
|
const style = document.createElement('style')
|
|
style.textContent = /* css */`
|
|
page-create .create-page {
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
padding: var(--space-lg) 0;
|
|
}
|
|
|
|
page-create .create-page h1 {
|
|
margin-bottom: var(--space-xl);
|
|
}
|
|
|
|
page-create .create-form .form-group {
|
|
margin-bottom: var(--space-lg);
|
|
}
|
|
|
|
page-create textarea.input {
|
|
resize: vertical;
|
|
min-height: 120px;
|
|
}
|
|
|
|
page-create .image-upload {
|
|
border: 2px dashed var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
page-create .upload-area {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--space-2xl);
|
|
cursor: pointer;
|
|
transition: background-color var(--transition-fast);
|
|
}
|
|
|
|
page-create .upload-area:hover {
|
|
background-color: var(--color-bg-secondary);
|
|
}
|
|
|
|
page-create .upload-icon {
|
|
font-size: 2rem;
|
|
margin-bottom: var(--space-sm);
|
|
}
|
|
|
|
page-create .image-previews {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
gap: var(--space-sm);
|
|
padding: var(--space-sm);
|
|
}
|
|
|
|
page-create .image-previews:empty {
|
|
display: none;
|
|
}
|
|
|
|
page-create .image-preview {
|
|
position: relative;
|
|
aspect-ratio: 1;
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
}
|
|
|
|
page-create .image-preview img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
page-create .remove-image {
|
|
position: absolute;
|
|
top: var(--space-xs);
|
|
right: var(--space-xs);
|
|
width: 24px;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-error);
|
|
color: white;
|
|
border-radius: var(--radius-full);
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity var(--transition-fast);
|
|
}
|
|
|
|
page-create .image-preview:hover .remove-image {
|
|
opacity: 1;
|
|
}
|
|
|
|
page-create .field-hint {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-muted);
|
|
margin-top: var(--space-xs);
|
|
}
|
|
|
|
page-create .form-row {
|
|
display: flex;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
page-create .form-group-price {
|
|
flex: 2;
|
|
}
|
|
|
|
page-create .form-group-currency {
|
|
flex: 1;
|
|
}
|
|
|
|
page-create .checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
page-create .checkbox-label input {
|
|
width: 18px;
|
|
height: 18px;
|
|
accent-color: var(--color-accent);
|
|
}
|
|
|
|
page-create .login-required {
|
|
text-align: center;
|
|
padding: var(--space-3xl) 0;
|
|
}
|
|
|
|
page-create .login-required h2 {
|
|
margin-bottom: var(--space-lg);
|
|
}
|
|
|
|
page-create .form-actions {
|
|
display: flex;
|
|
gap: var(--space-md);
|
|
justify-content: flex-end;
|
|
margin-top: var(--space-xl);
|
|
}
|
|
|
|
page-create .form-error {
|
|
padding: var(--space-md);
|
|
background: var(--color-bg-tertiary);
|
|
border: 1px solid var(--color-error);
|
|
border-radius: var(--radius-md);
|
|
color: var(--color-text);
|
|
margin-bottom: var(--space-md);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
page-create .draft-notice {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-md);
|
|
padding: var(--space-sm) var(--space-md);
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
margin-bottom: var(--space-lg);
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
page-create .btn-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-primary);
|
|
font-size: var(--font-size-sm);
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
page-create .btn-link:hover {
|
|
color: var(--color-primary-hover);
|
|
}
|
|
`
|
|
document.head.appendChild(style)
|