Files
kashilo/js/components/pages/page-listing.js

1106 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import { auth } from '../../services/auth.js'
import { favoritesService } from '../../services/favorites.js'
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js'
import '../chat-widget.js'
import '../location-map.js'
import '../listing-card.js'
import { categoriesService } from '../../services/categories.js'
class PageListing extends HTMLElement {
constructor() {
super()
this.listing = null
this.sellerListings = []
this.loading = true
this.isFavorite = false
this.rates = null
this.isOwner = false
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
}
connectedCallback() {
this.listingId = this.dataset.id
this.render()
this.loadListing()
this.unsubscribe = i18n.subscribe(() => this.render())
window.addEventListener('currency-changed', this.handleCurrencyChange)
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
window.removeEventListener('currency-changed', this.handleCurrencyChange)
this.resetMetaTags()
}
handleCurrencyChange() {
this.render()
}
async loadListing() {
try {
this.listing = await directus.getListing(this.listingId)
this.rates = await getXmrRates()
this.loadFavoriteState()
// Check if current user is owner
await this.checkOwnership()
// Increment view counter only if not owner
if (!this.isOwner) {
const newViews = await directus.incrementListingViews(this.listingId)
if (newViews !== null) {
this.listing.views = newViews
}
}
// Load other listings from same seller
if (this.listing?.user_created) {
await this.loadSellerListings()
}
} catch (e) {
console.error('Failed to load listing:', e)
this.listing = null
}
this.loading = false
this.render()
this.setupEventListeners()
this.updateMetaTags()
}
updateMetaTags() {
if (!this.listing) return
const title = `${this.listing.title} dgray.io`
const description = (this.listing.description || '').substring(0, 160)
const imageId = this.listing.images?.[0]?.directus_files_id?.id || this.listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getFileUrl(imageId, { width: 1200, height: 630, fit: 'cover' }) : 'https://dgray.io/assets/press/og-image.png'
const url = `https://dgray.io/#/listing/${this.listing.id}`
document.title = title
this._setMeta('description', description)
this._setMeta('og:title', title, true)
this._setMeta('og:description', description, true)
this._setMeta('og:image', imageUrl, true)
this._setMeta('og:url', url, true)
this._setMeta('og:type', 'product', true)
this._setMeta('twitter:title', title)
this._setMeta('twitter:description', description)
this._setMeta('twitter:image', imageUrl)
}
resetMetaTags() {
const defaultTitle = 'dgray.io Anonymous Classifieds with Monero'
const defaultDesc = 'Buy and sell anonymously with Monero. No KYC, no email, E2E encrypted chat.'
const defaultImage = 'https://dgray.io/assets/press/og-image.png'
document.title = defaultTitle
this._setMeta('description', defaultDesc)
this._setMeta('og:title', defaultTitle, true)
this._setMeta('og:description', defaultDesc, true)
this._setMeta('og:image', defaultImage, true)
this._setMeta('og:url', 'https://dgray.io', true)
this._setMeta('og:type', 'website', true)
this._setMeta('twitter:title', defaultTitle)
this._setMeta('twitter:description', defaultDesc)
this._setMeta('twitter:image', defaultImage)
}
_setMeta(name, content, isProperty = false) {
const attr = isProperty ? 'property' : 'name'
let el = document.querySelector(`meta[${attr}="${name}"]`)
if (el) {
el.setAttribute('content', content)
} else {
el = document.createElement('meta')
el.setAttribute(attr, name)
el.setAttribute('content', content)
document.head.appendChild(el)
}
}
async checkOwnership() {
if (!auth.isLoggedIn() || !this.listing?.user_created) {
this.isOwner = false
return
}
try {
const user = await auth.getUser()
this.isOwner = user?.id === this.listing.user_created
} catch (e) {
this.isOwner = false
}
}
async loadSellerListings() {
try {
const response = await directus.getListings({
filter: {
status: { _eq: 'published' },
user_created: { _eq: this.listing.user_created },
id: { _neq: this.listing.id }
},
limit: 4
})
this.sellerListings = response.items || []
} catch (e) {
console.error('Failed to load seller listings:', e)
this.sellerListings = []
}
}
loadFavoriteState() {
this.isFavorite = favoritesService.isFavorite(this.listingId)
}
toggleFavorite() {
favoritesService.toggle(this.listingId)
this.isFavorite = !this.isFavorite
const btn = this.querySelector('#favorite-btn')
btn?.classList.toggle('active', this.isFavorite)
btn.innerHTML = this.getFavoriteIcon()
}
async copyShareLink() {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
const btn = this.querySelector('#share-btn')
btn?.classList.add('copied')
setTimeout(() => btn?.classList.remove('copied'), 2000)
} catch (e) {
console.error('Failed to copy:', e)
}
}
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>${t('listing.notFound')}</p>
<a href="#/" class="btn btn-primary">${t('listing.backHome')}</a>
</div>
`
return
}
if (this.listing.status === 'deleted' && !this.isOwner) {
this.innerHTML = /* html */`
<div class="empty-state">
<div class="empty-state-icon">🗑️</div>
<p>${t('messages.listingRemoved')}</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]) : null
this.allImages = images
const categoryName = this.listing.category
? categoriesService.getTranslatedName(this.listing.category)
: ''
const priceInfo = this.getFormattedPrice()
const createdDate = this.listing.date_created
? new Date(this.listing.date_created).toLocaleDateString()
: ''
this.innerHTML = /* html */`
<article class="listing-detail">
<!-- Two Column Layout -->
<div class="listing-layout">
<!-- Left Column: Gallery, Header, Description, More Listings -->
<div class="listing-main">
<!-- Gallery -->
<div class="listing-gallery">
<div class="listing-image-main" id="main-image">
${firstImage
? `<img src="${firstImage}" alt="${escapeHTML(this.listing.title)}" id="main-img">`
: this.getPlaceholderSvg()}
</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>
<!-- Header -->
<header class="listing-header">
${categoryName ? `<a href="#/?category=${this.listing.category?.slug}" class="badge badge-primary">${escapeHTML(categoryName)}</a>` : ''}
<h1>${escapeHTML(this.listing.title)}</h1>
<div class="listing-price-wrapper">
<p class="listing-price">${priceInfo.primary}</p>
${priceInfo.secondary ? `<p class="listing-price-secondary">${priceInfo.secondary}</p>` : ''}
</div>
<div class="listing-meta">
${this.listing.condition ? `<span class="meta-item">${this.getConditionLabel(this.listing.condition)}</span>` : ''}
${this.listing.shipping ? `<span class="meta-item">📦 ${t('listing.shippingAvailable')}</span>` : ''}
<span class="meta-item views-item"><span class="views-icon">👁</span> ${this.formatViews(this.listing.views || 0)}</span>
${this.listing.expires_at ? `<span class="meta-item expires-item">${this.formatExpiresAt(this.listing.expires_at)}</span>` : ''}
<span class="meta-item meta-date">${t('listing.postedOn')} ${createdDate}</span>
</div>
</header>
<!-- Sidebar Mobile (shown only on mobile, between header and description) -->
<div class="sidebar-mobile">
${this.renderSidebar()}
</div>
<!-- Description -->
<section class="listing-description">
<div class="description-text">${this.formatDescription(this.listing.description)}</div>
</section>
<!-- Location Mobile (shown only on mobile) -->
${this.listing.location ? `
<section class="listing-location-section location-mobile">
<h2>${t('listing.location')}</h2>
<location-map
name="${escapeHTML(this.listing.location.name || '')}"
postal-code="${escapeHTML(this.listing.location.postal_code || '')}"
country="${escapeHTML(this.listing.location.country || '')}"
></location-map>
</section>
` : ''}
<!-- Seller's Other Listings -->
${this.sellerListings.length > 0 ? `
<section class="seller-listings">
<h2>${t('listing.moreFromSeller')}</h2>
<div class="seller-listings-grid">
${this.sellerListings.map(listing => this.renderListingCard(listing)).join('')}
</div>
</section>
` : ''}
</div>
<!-- Right Column: Sidebar, Location (Desktop only) -->
<aside class="listing-sidebar sidebar-desktop">
${this.renderSidebar()}
<!-- Location Desktop -->
${this.listing.location ? `
<section class="listing-location-section">
<h2>${t('listing.location')}</h2>
<location-map
name="${escapeHTML(this.listing.location.name || '')}"
postal-code="${escapeHTML(this.listing.location.postal_code || '')}"
country="${escapeHTML(this.listing.location.country || '')}"
></location-map>
</section>
` : ''}
</aside>
</div>
</article>
${this.renderContactDialog()}
`
}
renderSidebar() {
// Owner view: show edit button instead of contact
if (this.isOwner) {
const paymentProcessing = this.listing?.payment_status === 'processing'
return /* html */`
${paymentProcessing ? `
<div class="sidebar-card payment-processing-card">
<div class="processing-badge">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<strong>${t('payment.awaitingConfirmation')}</strong>
</div>
<p class="processing-hint">${t('payment.awaitingHint')}</p>
</div>
` : ''}
<div class="sidebar-card">
<a href="#/edit/${this.listingId}" class="btn btn-primary btn-lg sidebar-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
${t('listing.edit')}
</a>
<div class="sidebar-actions">
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
<span>${t('listing.share')}</span>
</button>
</div>
</div>
`
}
return /* html */`
<div class="sidebar-card">
<button class="btn btn-primary btn-lg sidebar-btn" id="contact-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
${t('listing.contactSeller')}
</button>
<div class="sidebar-actions">
<button class="action-btn ${this.isFavorite ? 'active' : ''}" id="favorite-btn" title="${t('home.addFavorite')}">
${this.getFavoriteIcon()}
<span>${this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite')}</span>
</button>
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
<span>${t('listing.share')}</span>
</button>
<button class="action-btn" id="report-btn" title="${t('listing.report')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>
<line x1="4" y1="22" x2="4" y2="15"></line>
</svg>
<span>${t('listing.report')}</span>
</button>
</div>
</div>
<!-- Seller Card -->
<div class="sidebar-card seller-card">
<div class="seller-header">
<div class="seller-avatar">?</div>
<div class="seller-info">
<strong>${t('listing.anonymousSeller')}</strong>
<span>${t('listing.memberSince')} 2024</span>
</div>
</div>
</div>
`
}
renderListingCard(listing) {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
const locationName = listing.location?.name || ''
return /* html */`
<listing-card
listing-id="${listing.id}"
title="${escapeHTML(listing.title || '')}"
price="${listing.price || ''}"
currency="${listing.currency || 'EUR'}"
location="${escapeHTML(locationName)}"
image="${imageUrl}"
owner-id="${listing.user_created || ''}"
></listing-card>
`
}
renderContactDialog() {
return /* html */`
<dialog class="contact-dialog" id="contact-dialog">
<button class="dialog-close" id="dialog-close" aria-label="${t('common.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 || ''}"
seller-public-key="${this.listing?.seller_public_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-addr-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>
`
}
getFavoriteIcon() {
return this.isFavorite
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>`
}
getPlaceholderSvg() {
return /* 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>
`
}
setupEventListeners() {
// Contact dialog
const contactBtn = this.querySelector('#contact-btn')
const dialog = this.querySelector('#contact-dialog')
const closeBtn = this.querySelector('#dialog-close')
contactBtn?.addEventListener('click', () => dialog?.showModal())
closeBtn?.addEventListener('click', () => dialog?.close())
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close()
})
// Dialog tabs
this.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab
this.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'))
btn.classList.add('active')
this.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== `tab-${tab}`)
})
})
})
// Copy Monero address
this.querySelector('#copy-addr-btn')?.addEventListener('click', async () => {
const addr = this.querySelector('#monero-addr')?.textContent
if (addr) {
await navigator.clipboard.writeText(addr)
const btn = this.querySelector('#copy-addr-btn')
btn?.classList.add('copied')
setTimeout(() => btn?.classList.remove('copied'), 2000)
}
})
// Favorite button
this.querySelector('#favorite-btn')?.addEventListener('click', () => this.toggleFavorite())
// Share button
this.querySelector('#share-btn')?.addEventListener('click', () => this.copyShareLink())
// Thumbnail gallery
this.querySelectorAll('.thumbnail').forEach(thumb => {
thumb.addEventListener('click', () => {
const index = parseInt(thumb.dataset.index)
const mainImg = this.querySelector('#main-img')
if (mainImg && this.allImages[index]) {
mainImg.src = this.getImageUrl(this.allImages[index])
this.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('active'))
thumb.classList.add('active')
}
})
})
}
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 && price !== 0) return t('listing.priceOnRequest')
if (currency === 'XMR') {
return `${parseFloat(price).toFixed(4)} XMR`
}
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: currency
}).format(price)
}
getFormattedPrice() {
const { price, currency } = this.listing
if (!price && price !== 0) {
return { primary: t('listing.priceOnRequest'), secondary: null }
}
if (this.rates) {
const listing = {
price: parseFloat(price),
currency,
price_mode: currency === 'XMR' ? 'xmr' : 'fiat'
}
return formatCurrencyPrice(listing, this.rates)
}
return {
primary: this.formatPrice(price, currency),
secondary: null
}
}
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
}
formatExpiresAt(expiresAt) {
const expires = new Date(expiresAt)
const now = new Date()
const diffMs = expires - now
if (diffMs <= 0) {
return `${t('listing.expired')}`
}
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
return `${t('listing.expiresIn1Day')}`
}
return `${t('listing.expiresInDays', { days: diffDays })}`
}
formatViews(count) {
if (count === 1) {
return `1 ${t('listing.viewSingular')}`
}
return `${count} ${t('listing.viewPlural')}`
}
formatDescription(text) {
if (!text) return ''
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
}
customElements.define('page-listing', PageListing)
const style = document.createElement('style')
style.textContent = /* css */`
page-listing .listing-detail {
padding: var(--space-lg) 0;
}
/* Gallery - Full Width */
page-listing .listing-gallery {
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: var(--space-xl);
}
page-listing .listing-image-main {
display: block;
width: 100%;
overflow: hidden;
border-radius: var(--radius-md);
}
page-listing .listing-image-main img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius-md);
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
page-listing .listing-image-main:hover img {
transform: scale(1.08);
}
page-listing .listing-image-main .placeholder-icon {
width: 80px;
height: 80px;
color: var(--color-border);
}
page-listing .listing-thumbnails {
display: flex;
gap: var(--space-xs);
padding: var(--space-sm);
overflow-x: auto;
}
page-listing .thumbnail {
flex-shrink: 0;
width: 80px;
height: 80px;
border: 2px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
padding: 0;
background: var(--color-bg-tertiary);
}
page-listing .thumbnail.active {
border-color: var(--color-primary);
}
page-listing .thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Two Column Layout */
page-listing .listing-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-xl);
align-items: start;
}
page-listing .listing-main {
min-width: 0;
}
/* Mobile: Hide desktop sidebar, show mobile elements */
page-listing .sidebar-mobile {
display: none;
}
page-listing .location-mobile {
display: none;
}
@media (max-width: 768px) {
page-listing .listing-layout {
grid-template-columns: 1fr;
}
page-listing .listing-sidebar.sidebar-desktop {
display: none;
}
page-listing .sidebar-mobile {
display: flex;
margin-bottom: var(--space-xl);
}
page-listing .location-mobile {
display: block;
}
page-listing .sidebar-card {
padding: var(--space-md);
}
}
page-listing .listing-header {
margin-bottom: var(--space-xl);
}
page-listing .listing-header h1 {
margin: var(--space-sm) 0;
font-size: var(--font-size-2xl);
}
page-listing > .listing-detail .listing-header .listing-price-wrapper {
margin: var(--space-sm) 0;
}
page-listing > .listing-detail .listing-header .listing-price-wrapper > .listing-price {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
margin: 0;
}
page-listing > .listing-detail .listing-header .listing-price-wrapper > .listing-price-secondary {
font-size: var(--font-size-base);
color: var(--color-text-muted);
margin: var(--space-xs) 0 0;
}
page-listing .listing-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: var(--space-md);
}
page-listing .meta-item {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
page-listing .meta-date {
margin-left: auto;
}
page-listing .views-icon {
filter: grayscale(1);
}
page-listing .listing-description {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin-bottom: var(--space-xl);
}
page-listing .listing-location-section {
margin-bottom: var(--space-xl);
}
page-listing .listing-description h2,
page-listing .listing-location-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-md);
}
page-listing .description-text {
line-height: 1.6;
color: var(--color-text-secondary);
}
/* Sidebar */
page-listing .listing-sidebar {
display: flex;
flex-direction: column;
gap: var(--space-md);
position: sticky;
top: calc(3 * var(--space-xl));
}
page-listing .sidebar-mobile {
flex-direction: column;
gap: var(--space-md);
}
page-listing .sidebar-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
page-listing .sidebar-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
}
page-listing .sidebar-actions {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
page-listing .action-btn {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
}
page-listing .action-btn:hover {
background: var(--color-bg-tertiary);
color: var(--color-text);
}
page-listing .action-btn.active {
color: var(--color-accent);
background: var(--color-bg-tertiary);
}
page-listing .action-btn.copied {
color: var(--color-success);
}
/* Seller Card */
page-listing .seller-header {
display: flex;
align-items: center;
gap: var(--space-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);
}
/* Seller Listings */
page-listing .seller-listings {
margin-top: var(--space-3xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border);
}
page-listing .seller-listings h2 {
margin-bottom: var(--space-lg);
}
page-listing .seller-listings-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
}
@media (max-width: 768px) {
page-listing .seller-listings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 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);
background: transparent;
border: none;
cursor: pointer;
}
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);
background: transparent;
border: none;
cursor: pointer;
}
page-listing .dialog-close:hover {
color: var(--color-text);
}
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;
}
page-listing .btn-copy {
padding: var(--space-sm);
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;
}
/* Payment Processing Badge (sidebar) */
page-listing .payment-processing-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-warning, #e6a700);
}
page-listing .processing-badge {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-warning, #e6a700);
}
page-listing .processing-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-top: var(--space-sm);
line-height: 1.5;
}
/* Loading & Empty States */
page-listing .loading {
display: flex;
justify-content: center;
padding: var(--space-3xl);
}
page-listing .empty-state {
text-align: center;
padding: var(--space-3xl);
}
page-listing .empty-state-icon {
font-size: 3rem;
margin-bottom: var(--space-md);
}
`
document.head.appendChild(style)