Files
kashilo/js/components/pages/page-listing.js
2026-01-31 17:28:43 +01:00

566 lines
18 KiB
JavaScript

import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import '../chat-widget.js'
class PageListing extends HTMLElement {
constructor() {
super()
this.listing = null
this.loading = true
}
connectedCallback() {
this.listingId = this.dataset.id
this.render()
this.loadListing()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
}
async loadListing() {
try {
this.listing = await directus.getListing(this.listingId)
} catch (e) {
console.error('Failed to load listing:', e)
this.listing = null
}
this.loading = false
this.render()
}
render() {
if (this.loading) {
this.innerHTML = /* html */`
<div class="loading">
<div class="spinner"></div>
</div>
`
return
}
if (!this.listing) {
this.innerHTML = /* html */`
<div class="empty-state">
<div class="empty-state-icon">😕</div>
<p data-i18n="listing.notFound">${t('listing.notFound')}</p>
<a href="#/" class="btn btn-primary">${t('listing.backHome')}</a>
</div>
`
return
}
const images = (this.listing.images || []).slice(0, 5)
const hasImages = images.length > 0
const firstImage = hasImages ? this.getImageUrl(images[0], 800) : null
this.currentImageIndex = 0
this.allImages = images
const placeholderSvg = /* html */`
<svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`
const categoryName = this.listing.category?.name || this.listing.category?.slug || ''
const price = this.formatPrice(this.listing.price, this.listing.currency)
const createdDate = this.listing.date_created
? new Date(this.listing.date_created).toLocaleDateString()
: ''
this.innerHTML = /* html */`
<article class="listing-detail">
<div class="listing-gallery">
<div class="listing-image-main" id="main-image">
${firstImage
? `<img src="${firstImage}" alt="${this.escapeHtml(this.listing.title)}" id="main-img">`
: placeholderSvg}
</div>
${images.length > 1 ? `
<div class="listing-thumbnails">
${images.map((img, i) => `
<button class="thumbnail ${i === 0 ? 'active' : ''}" data-index="${i}">
<img src="${this.getImageUrl(img, 150)}" alt="">
</button>
`).join('')}
</div>
` : ''}
</div>
<div class="listing-info">
<header>
${categoryName ? `<span class="badge badge-primary">${this.escapeHtml(categoryName)}</span>` : ''}
<h1>${this.escapeHtml(this.listing.title)}</h1>
<p class="listing-price">${price}</p>
${this.listing.condition ? `<p class="listing-condition">${this.getConditionLabel(this.listing.condition)}</p>` : ''}
</header>
<section class="listing-description">
<h2 data-i18n="listing.description">${t('listing.description')}</h2>
<div class="description-text">${this.formatDescription(this.listing.description)}</div>
</section>
<section class="listing-seller">
<h2 data-i18n="listing.seller">${t('listing.seller')}</h2>
<div class="seller-card">
<div class="seller-avatar">?</div>
<div class="seller-info">
<strong>${t('listing.anonymousSeller')}</strong>
${createdDate ? `<span>${t('listing.postedOn')} ${createdDate}</span>` : ''}
</div>
</div>
</section>
${this.listing.shipping ? `
<section class="listing-shipping">
<span class="shipping-badge">📦 ${t('listing.shippingAvailable')}</span>
</section>
` : ''}
<div class="listing-actions">
<button class="btn btn-primary btn-lg" id="contact-btn">
${t('listing.contactSeller')}
</button>
</div>
</div>
</article>
<!-- Contact Dialog -->
<dialog class="contact-dialog" id="contact-dialog">
<button class="dialog-close" id="dialog-close" aria-label="Close">
<svg width="24" height="24" 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 class="dialog-tabs">
<button class="tab-btn active" data-tab="chat">${t('chat.title')}</button>
<button class="tab-btn" data-tab="payment">${t('listing.moneroAddress')}</button>
</div>
<div class="tab-content" id="tab-chat">
<chat-widget
listing-id="${this.listing.id}"
recipient-id="${this.listing.user_created?.id || ''}"
recipient-key=""
recipient-name="${t('listing.anonymousSeller')}"
></chat-widget>
</div>
<div class="tab-content hidden" id="tab-payment">
<p class="dialog-subtitle">${t('listing.paymentInfo')}</p>
<div class="monero-section">
<label>${t('listing.moneroAddress')}</label>
<div class="monero-address">
<code id="monero-addr">${this.listing.monero_address || t('listing.noMoneroAddress')}</code>
${this.listing.monero_address ? `
<button class="btn btn-outline btn-copy" id="copy-btn" title="${t('listing.copyAddress')}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
` : ''}
</div>
</div>
<p class="dialog-hint">${t('listing.contactHint')}</p>
</div>
</dialog>
`
this.setupEventListeners()
}
setupEventListeners() {
const contactBtn = this.querySelector('#contact-btn')
const dialog = this.querySelector('#contact-dialog')
const closeBtn = this.querySelector('#dialog-close')
const copyBtn = this.querySelector('#copy-btn')
const tabBtns = this.querySelectorAll('.tab-btn')
contactBtn?.addEventListener('click', () => {
dialog?.showModal()
})
closeBtn?.addEventListener('click', () => {
dialog?.close()
})
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.close()
}
})
copyBtn?.addEventListener('click', async () => {
const addr = this.querySelector('#monero-addr')?.textContent
if (addr) {
await navigator.clipboard.writeText(addr)
copyBtn.classList.add('copied')
setTimeout(() => copyBtn.classList.remove('copied'), 2000)
}
})
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab
tabBtns.forEach(b => b.classList.remove('active'))
btn.classList.add('active')
this.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== `tab-${tab}`)
})
})
})
// Thumbnail gallery
const thumbnails = this.querySelectorAll('.thumbnail')
const mainImg = this.querySelector('#main-img')
thumbnails.forEach(thumb => {
thumb.addEventListener('click', () => {
const index = parseInt(thumb.dataset.index)
if (mainImg && this.allImages[index]) {
mainImg.src = this.getImageUrl(this.allImages[index], 800)
thumbnails.forEach(t => t.classList.remove('active'))
thumb.classList.add('active')
this.currentImageIndex = index
}
})
})
}
getImageUrl(image, size = null) {
const fileId = image?.directus_files_id?.id || image?.directus_files_id || image
if (!fileId) return null
return size
? directus.getThumbnailUrl(fileId, size)
: directus.getFileUrl(fileId)
}
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return t('listing.priceOnRequest')
if (currency === 'XMR') {
return `${parseFloat(price).toFixed(4)} XMR`
}
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency
}).format(price)
}
formatDescription(desc) {
if (!desc) return ''
return this.escapeHtml(desc).replace(/\n/g, '<br>')
}
getConditionLabel(condition) {
const labels = {
new: t('create.conditionNew'),
like_new: t('create.conditionLikeNew'),
good: t('create.conditionGood'),
fair: t('create.conditionFair'),
poor: t('create.conditionPoor')
}
return labels[condition] || condition
}
escapeHtml(text) {
if (!text) return ''
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-listing', PageListing)
const style = document.createElement('style')
style.textContent = /* css */`
page-listing .listing-detail {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--space-xl);
padding: var(--space-lg) 0;
}
@media (max-width: 900px) {
page-listing .listing-detail {
grid-template-columns: 1fr;
}
}
page-listing .listing-gallery {
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
align-self: start;
}
page-listing .listing-image-main {
aspect-ratio: 4 / 3;
background: var(--color-bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
page-listing .listing-image-main img {
width: 100%;
height: 100%;
object-fit: contain;
background: var(--color-bg-tertiary);
}
page-listing .listing-image-main .placeholder-icon {
width: 80px;
height: 80px;
color: var(--color-border);
}
page-listing .listing-thumbnails {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-xs);
padding: var(--space-xs);
background: var(--color-bg-secondary);
}
@media (max-width: 480px) {
page-listing .listing-thumbnails {
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: 4px;
}
}
page-listing .thumbnail {
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--transition-fast), opacity var(--transition-fast);
padding: 0;
background: var(--color-bg-tertiary);
}
page-listing .thumbnail:hover {
opacity: 0.8;
}
page-listing .thumbnail.active {
border-color: var(--color-primary);
}
page-listing .thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
page-listing .listing-info header {
margin-bottom: var(--space-xl);
}
page-listing .listing-info h1 {
margin: var(--space-sm) 0;
}
page-listing .listing-price {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
}
page-listing .listing-location {
color: var(--color-text-secondary);
margin-top: var(--space-sm);
}
page-listing .listing-description,
page-listing .listing-seller {
margin-bottom: var(--space-xl);
}
page-listing .listing-description h2,
page-listing .listing-seller h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-md);
}
page-listing .seller-card {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
}
page-listing .seller-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
page-listing .seller-info {
display: flex;
flex-direction: column;
}
page-listing .seller-info span {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
page-listing .listing-actions {
margin-top: var(--space-xl);
}
page-listing .listing-actions .btn {
width: 100%;
}
/* Dialog */
page-listing .contact-dialog {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
max-width: 500px;
width: calc(100% - 2 * var(--space-md));
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
page-listing .contact-dialog::backdrop {
background: var(--color-overlay);
}
page-listing .dialog-tabs {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-sm);
}
page-listing .tab-btn {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
page-listing .tab-btn:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
page-listing .tab-btn.active {
color: var(--color-primary);
background: var(--color-primary-light);
}
page-listing .tab-content.hidden {
display: none;
}
page-listing .dialog-close {
position: absolute;
top: var(--space-md);
right: var(--space-md);
padding: var(--space-xs);
color: var(--color-text-muted);
transition: color var(--transition-fast);
}
page-listing .dialog-close:hover {
color: var(--color-text);
}
page-listing .contact-dialog h2 {
margin-bottom: var(--space-sm);
}
page-listing .dialog-subtitle {
color: var(--color-text-secondary);
margin-bottom: var(--space-lg);
}
page-listing .monero-section {
margin-bottom: var(--space-lg);
}
page-listing .monero-section label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-sm);
color: var(--color-text-secondary);
}
page-listing .monero-address {
display: flex;
gap: var(--space-sm);
align-items: stretch;
}
page-listing .monero-address code {
flex: 1;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
word-break: break-all;
line-height: 1.4;
color: var(--color-text);
}
page-listing .btn-copy {
padding: var(--space-sm);
border-color: var(--color-border);
color: var(--color-text);
flex-shrink: 0;
}
page-listing .btn-copy.copied {
background: var(--color-success);
border-color: var(--color-success);
color: white;
}
page-listing .dialog-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
`
document.head.appendChild(style)