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' import { reputationService } from '../../services/reputation.js' import { generatePseudonym, generateAvatar } from '../../services/identity.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.sellerReputation = null this.handleCurrencyChange = this.handleCurrencyChange.bind(this) } connectedCallback() { this.listingId = this.dataset.id this._unsubs = [] this.render() this.loadListing() this._unsubs.push(i18n.subscribe(() => this.render())) window.addEventListener('currency-changed', this.handleCurrencyChange) } disconnectedCallback() { this._unsubs.forEach(fn => fn()) this._unsubs = [] 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() } await this.loadSellerReputation() } catch (e) { console.error('Failed to load listing:', e) this.listing = null } this.loading = false this.render() this.setupEventListeners() this.updateMetaTags() if (this.dataset.chat && auth.isLoggedIn()) { const chatWidget = this.querySelector('chat-widget') if (this.dataset.chat !== '1') { chatWidget?.setAttribute('conversation-id', this.dataset.chat) } const dialog = this.querySelector('#contact-dialog') dialog?.showModal() chatWidget?.activate() } } updateMetaTags() { if (!this.listing) return const title = `${this.listing.title} โ€“ kashilo.com` 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://kashilo.com/assets/press/og-image.png' const url = `https://kashilo.com/#/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 = 'kashilo.com โ€“ Private Classifieds with Monero' const defaultDesc = 'Buy and sell privately with Monero. No account needed, E2E encrypted chat.' const defaultImage = 'https://kashilo.com/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://kashilo.com', 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 = [] } } async loadSellerReputation() { if (!this.listing?.id) return try { const convsResponse = await directus.get('/items/conversations', { filter: { listing_id: { _eq: this.listing.id } }, fields: ['participant_hash_2'], limit: 1 }) const conv = (convsResponse.data || [])[0] if (conv?.participant_hash_2) { this.sellerReputation = await reputationService.getReputation(conv.participant_hash_2) } } catch (e) { // No conversations yet = no reputation to show } } 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 */`
` return } if (!this.listing) { this.innerHTML = /* html */`
๐Ÿ˜•

${t('listing.notFound')}

${t('listing.backHome')}
` return } if (this.listing.status !== 'published' && !this.isOwner) { this.innerHTML = /* html */`
๐Ÿ˜•

${t('listing.notFound')}

${t('listing.backHome')}
` 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 */`
${categoryName ? `${escapeHTML(categoryName)}` : ''}

${escapeHTML(this.listing.title)}

${priceInfo.primary}

${priceInfo.secondary ? `

${priceInfo.secondary}

` : ''}
${this.listing.condition ? `${this.getConditionLabel(this.listing.condition)}` : ''} ${this.listing.shipping ? `๐Ÿ“ฆ ${t('listing.shippingAvailable')}` : ''} ๐Ÿ‘ ${this.formatViews(this.listing.views || 0)} ${this.listing.expires_at ? `${this.formatExpiresAt(this.listing.expires_at)}` : ''} ${t('listing.postedOn')} ${createdDate}
${this.formatDescription(this.listing.description)}
${this.listing.location ? `

${t('listing.location')}

` : ''} ${this.sellerListings.length > 0 ? `

${t('listing.moreFromSeller')}

${this.sellerListings.map(listing => this.renderListingCard(listing)).join('')}
` : ''}
${this.renderContactDialog()} ` } renderSidebar() { // Owner view: show edit button instead of contact if (this.isOwner) { const paymentProcessing = this.listing?.payment_status === 'processing' return /* html */` ${paymentProcessing ? ` ` : ''} ` } return /* html */` ` } renderSellerReputation() { if (!this.sellerReputation) return '' const rep = this.sellerReputation const levelInfo = reputationService.getLevelInfo(rep.level) const dealCount = rep.deals_completed const dealText = dealCount === 1 ? t('reputation.dealsSingular') : t('reputation.deals', { count: dealCount }) let ratingHtml = '' if (rep.avg_rating > 0) { const stars = 'โ˜…'.repeat(Math.round(rep.avg_rating)) + 'โ˜†'.repeat(5 - Math.round(rep.avg_rating)) ratingHtml = /* html */`${stars}` } const showWarning = rep.level === 'new' return /* html */`
${levelInfo.badge} ${t(levelInfo.i18nKey)} ${dealCount > 0 ? `ยท ${dealText}` : ''}
${ratingHtml}
${showWarning ? /* html */`
${t('reputation.newWarning')}
` : ''} ` } renderListingCard(listing) { const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : '' const locationName = listing.location?.name || '' return /* html */` ` } renderContactDialog() { return /* html */`
` } getFavoriteIcon() { return this.isFavorite ? ` ` : ` ` } getPlaceholderSvg() { return /* html */` ` } setupEventListeners() { // Contact dialog const dialog = this.querySelector('#contact-dialog') const closeBtn = this.querySelector('#dialog-close') this.querySelectorAll('#contact-btn').forEach(btn => { btn.addEventListener('click', () => { if (!auth.isLoggedIn()) { document.querySelector('auth-modal')?.show() return } dialog?.showModal() this.querySelector('chat-widget')?.activate() }) }) 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 escapeHTML(text).replace(/\n/g, '
') } } 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-btn + .sidebar-btn { margin-top: 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: var(--radius-full); overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } page-listing .seller-avatar svg { width: 48px; height: 48px; display: block; } 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 .seller-auto-label { font-size: var(--font-size-xs); color: var(--color-text-muted); font-style: italic; } page-listing .seller-reputation { padding: var(--space-sm) 0 0; margin-top: var(--space-sm); border-top: 1px solid var(--color-border); display: flex; flex-direction: column; gap: var(--space-xs); } page-listing .seller-level { display: flex; align-items: center; gap: var(--space-xs); font-size: var(--font-size-sm); } page-listing .level-badge { font-size: var(--font-size-base); } page-listing .level-name { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } page-listing .deal-count { color: var(--color-text-muted); } page-listing .seller-rating { font-size: var(--font-size-sm); color: var(--color-text-muted); letter-spacing: 1px; } page-listing .seller-warning { margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-xs); color: var(--color-text-muted); line-height: 1.5; } /* 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)