feat: add verifiable listings (proof of possession) with verification widget, badge on cards/detail, i18n (7 langs), fix edit prefill for location/monero, prevent edit/delete on pending listings

This commit is contained in:
2026-02-11 08:14:44 +01:00
parent f5cfab6a2a
commit 53673b4650
20 changed files with 754 additions and 34 deletions

View File

@@ -6,7 +6,7 @@ import { favoritesService } from '../services/favorites.js'
class ListingCard extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority']
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority', 'verified']
}
constructor() {
@@ -103,8 +103,9 @@ class ListingCard extends HTMLElement {
const paymentStatus = this.getAttribute('payment-status')
const status = this.getAttribute('status')
const isDeleted = status === 'deleted'
const isPending = status === 'draft' && paymentStatus !== 'paid'
const ownerBadge = (this.isOwner && !isDeleted) ? /* html */`
const ownerBadge = (this.isOwner && !isDeleted && !isPending) ? /* html */`
<a href="#/edit/${escapeHTML(id)}" class="owner-badge" title="${t('listing.edit')}">
<svg width="14" height="14" 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>
@@ -141,13 +142,16 @@ class ListingCard extends HTMLElement {
${paymentBadge}
</div>
<div class="listing-info">
<h3 class="listing-title">${escapeHTML(title)}</h3>
<div class="listing-price-wrapper">
<p class="listing-price">${priceDisplay}</p>
${secondaryPrice ? `<p class="listing-price-secondary">${secondaryPrice}</p>` : ''}
</div>
<h3 class="listing-title">${escapeHTML(title)}</h3>
<div class="listing-price-wrapper">
<p class="listing-price">${priceDisplay}</p>
${secondaryPrice ? `<p class="listing-price-secondary">${secondaryPrice}</p>` : ''}
</div>
<div class="listing-meta-row">
${this.getAttribute('verified') === 'true' ? `<span class="listing-verified-badge">${t('verification.badge')}</span>` : ''}
<p class="listing-location">${escapeHTML(location)}</p>
</div>
</div>
</${linkTag}>
${!isDeleted ? /* html */`
<button
@@ -331,6 +335,19 @@ style.textContent = /* css */`
text-overflow: ellipsis;
}
listing-card .listing-meta-row {
display: flex;
align-items: center;
gap: var(--space-xs);
}
listing-card .listing-verified-badge {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-success);
white-space: nowrap;
}
listing-card .listing-location {
font-size: var(--font-size-xs);
color: var(--color-text-muted);

View File

@@ -11,6 +11,7 @@ import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js'
import '../pow-captcha.js'
import '../image-cropper.js'
import '../verification-widget.js'
const STORAGE_KEY = 'kashilo_create_draft'
@@ -98,7 +99,10 @@ class PageCreate extends HTMLElement {
async loadExistingListing() {
try {
const listing = await directus.getListing(this.editId)
const response = await directus.get(`/items/listings/${this.editId}`, {
fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.*']
})
const listing = response.data
// Verify ownership
const user = await auth.getUser()
@@ -360,6 +364,16 @@ class PageCreate extends HTMLElement {
</div>
</div>
${this.editMode ? `
<div class="form-group">
<verification-widget
listing-id="${this.editId}"
${this.editListing?.verified ? `verified="true"` : ''}
${this.editListing?.verification_date ? `verified-date="${this.editListing.verification_date}"` : ''}
></verification-widget>
</div>
` : ''}
<div class="form-group">
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
<input
@@ -432,6 +446,22 @@ class PageCreate extends HTMLElement {
this.formData.location = e.detail.name
this.saveDraft()
})
// Pre-fill location picker in edit mode
if (this.editMode && this.editListing?.location && locationPicker) {
const loc = this.editListing.location
locationPicker.value = {
name: loc.name || '',
postalCode: loc.postal_code || '',
countryCode: loc.country || '',
displayName: loc.name || ''
}
this.formData.locationData = {
name: loc.name || '',
postalCode: loc.postal_code || '',
countryCode: loc.country || ''
}
}
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))

View File

@@ -117,6 +117,7 @@ class PageFavorites extends HTMLElement {
location="${escapeHTML(locationName)}"
image="${imageUrl}"
owner-id="${listing.user_created || ''}"
${listing.verified ? 'verified="true"' : ''}
></listing-card>
`
}).join('')

View File

@@ -534,6 +534,7 @@ class PageHome extends HTMLElement {
location="${escapeHTML(locationName)}"
image="${imageUrl}"
owner-id="${listing.user_created || ''}"
${listing.verified ? 'verified="true"' : ''}
${index < 4 ? 'priority' : ''}
></listing-card>
`

View File

@@ -171,7 +171,7 @@ class PageListing extends HTMLElement {
}
async loadSellerReputation() {
if (!this.listing?.id) return
if (!this.listing?.id || !auth.isLoggedIn()) return
try {
const convsResponse = await directus.get('/items/conversations', {
filter: { listing_id: { _eq: this.listing.id } },
@@ -290,6 +290,7 @@ class PageListing extends HTMLElement {
${priceInfo.secondary ? `<p class="listing-price-secondary">${priceInfo.secondary}</p>` : ''}
</div>
<div class="listing-meta">
${this.listing.verified ? `<span class="meta-item verified-badge">${t('verification.badge')}</span>` : ''}
${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>
@@ -308,6 +309,8 @@ class PageListing extends HTMLElement {
<div class="description-text">${this.formatDescription(this.listing.description)}</div>
</section>
${this.renderVerificationSection()}
<!-- Location Mobile (shown only on mobile) -->
${this.listing.location ? `
<section class="listing-location-section location-mobile">
@@ -358,6 +361,7 @@ class PageListing extends HTMLElement {
// Owner view: show edit button instead of contact
if (this.isOwner) {
const paymentProcessing = this.listing?.payment_status === 'processing'
const paymentPending = this.listing?.status === 'draft' && this.listing?.payment_status !== 'paid'
return /* html */`
${paymentProcessing ? `
@@ -373,16 +377,16 @@ class PageListing extends HTMLElement {
</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>
${!paymentPending ? `
<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">
@@ -482,6 +486,42 @@ class PageListing extends HTMLElement {
`
}
renderVerificationSection() {
if (!this.listing?.verified || !this.listing?.verification_image) return ''
const imageId = typeof this.listing.verification_image === 'object'
? this.listing.verification_image.id
: this.listing.verification_image
const imageUrl = directus.getFileUrl(imageId)
const code = this.listing.verification_code || ''
const date = this.listing.verification_date
? new Date(this.listing.verification_date).toLocaleDateString()
: ''
return /* html */`
<section class="verification-section">
<h2>${t('verification.verified')}</h2>
<p class="verification-hint">${t('verification.proofHint')}</p>
<div class="verification-proof">
<div class="verification-proof-image">
<img src="${imageUrl}" alt="${t('verification.proofPhoto')}" loading="lazy">
</div>
<div class="verification-proof-info">
<div class="verification-proof-code">
<span class="verification-proof-label">${t('verification.proofCode')}</span>
<span class="verification-proof-value">${escapeHTML(code)}</span>
</div>
${date ? `
<div class="verification-proof-date">
${t('verification.verifiedDate', { date })}
</div>
` : ''}
</div>
</div>
</section>
`
}
renderListingCard(listing) {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
@@ -496,6 +536,7 @@ class PageListing extends HTMLElement {
location="${escapeHTML(locationName)}"
image="${imageUrl}"
owner-id="${listing.user_created || ''}"
${listing.verified ? 'verified="true"' : ''}
></listing-card>
`
}
@@ -870,6 +911,12 @@ style.textContent = /* css */`
border-radius: var(--radius-sm);
}
page-listing .verified-badge {
background: var(--color-success);
color: #fff;
font-weight: var(--font-weight-medium);
}
page-listing .meta-date {
margin-left: auto;
}
@@ -886,11 +933,83 @@ style.textContent = /* css */`
margin-bottom: var(--space-xl);
}
page-listing .verification-section {
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 .verification-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0 0 var(--space-md);
}
page-listing .verification-proof {
display: flex;
gap: var(--space-lg);
align-items: flex-start;
}
page-listing .verification-proof-image {
flex-shrink: 0;
width: 200px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
}
page-listing .verification-proof-image img {
display: block;
width: 100%;
height: auto;
cursor: zoom-in;
}
page-listing .verification-proof-info {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
page-listing .verification-proof-label {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-xs);
}
page-listing .verification-proof-value {
font-family: monospace;
font-size: var(--font-size-2xl);
font-weight: 700;
letter-spacing: 0.3em;
color: var(--color-success);
}
page-listing .verification-proof-date {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
@media (max-width: 768px) {
page-listing .verification-proof {
flex-direction: column;
}
page-listing .verification-proof-image {
width: 100%;
}
}
page-listing .listing-location-section {
margin-bottom: var(--space-xl);
}
page-listing .listing-description h2,
page-listing .verification-section h2,
page-listing .listing-location-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-md);

View File

@@ -242,8 +242,10 @@ class PageMyListings extends HTMLElement {
const statusBadge = this.getStatusBadge(listing)
const isPublished = listing.status === 'published'
const isPending = listing.status === 'draft' && listing.payment_status !== 'paid'
let toggleBtn = ''
if (listingsService.canTogglePublish(listing)) {
if (!isPending && listingsService.canTogglePublish(listing)) {
const label = isPublished ? t('myListings.unpublish') : t('myListings.republish')
toggleBtn = /* html */`
<button class="btn-toggle-status" data-id="${listing.id}" data-status="${isPublished ? 'draft' : 'published'}">
@@ -257,7 +259,7 @@ class PageMyListings extends HTMLElement {
deleteBtn = /* html */`
<p class="deleted-hint">${t('myListings.deletedHint')}</p>
`
} else if (listing.status !== 'archived') {
} else if (!isPending && listing.status !== 'archived') {
deleteBtn = /* html */`
<button class="btn-delete-listing" data-id="${listing.id}">
${t('myListings.delete')}
@@ -278,6 +280,7 @@ class PageMyListings extends HTMLElement {
owner-id="${listing.user_created || ''}"
payment-status="${listing.payment_status || ''}"
status="${listing.status || ''}"
${listing.verified ? 'verified="true"' : ''}
></listing-card>
${toggleBtn}
${deleteBtn}

View File

@@ -0,0 +1,356 @@
import { t, i18n } from '../i18n.js'
import { verificationService } from '../services/verification.js'
class VerificationWidget extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'verified', 'verified-date']
}
constructor() {
super()
this.state = 'idle'
this.currentCode = null
this.timerInterval = null
this.i18nUnsubscribe = null
}
connectedCallback() {
const verified = this.getAttribute('verified')
if (verified === 'true') {
this.state = 'verified'
}
this.render()
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
this.clearTimer()
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
}
attributeChangedCallback() {
if (this.isConnected) {
const verified = this.getAttribute('verified')
if (verified === 'true') {
this.state = 'verified'
}
this.render()
}
}
clearTimer() {
if (this.timerInterval) {
clearInterval(this.timerInterval)
this.timerInterval = null
}
}
startVerification() {
this.currentCode = verificationService.generateCode()
this.state = 'active'
this.render()
this.startCountdown()
}
startCountdown() {
this.clearTimer()
this.timerInterval = setInterval(() => {
if (!this.currentCode) {
this.clearTimer()
return
}
const remaining = this.getRemainingTime()
if (remaining <= 0) {
this.clearTimer()
this.state = 'expired'
this.render()
this.setupEventListeners()
return
}
const timerEl = this.querySelector('.verification-timer')
if (timerEl) {
timerEl.textContent = this.formatTime(remaining)
if (remaining <= 60000) {
timerEl.classList.add('verification-timer--warning')
}
}
}, 1000)
}
getRemainingTime() {
if (!this.currentCode) return 0
const expiresAt = new Date(this.currentCode.expiresAt).getTime()
return Math.max(0, expiresAt - Date.now())
}
formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
async handleUpload(e) {
const file = e.target.files?.[0]
if (!file) return
const listingId = this.getAttribute('listing-id')
if (!listingId || !this.currentCode) return
this.state = 'uploading'
this.render()
try {
const success = await verificationService.verify(listingId, this.currentCode.code, file)
if (success) {
this.clearTimer()
this.state = 'verified'
this.render()
this.dispatchEvent(new CustomEvent('verification-complete', {
bubbles: true,
detail: { listingId }
}))
} else {
this.state = 'active'
this.render()
}
} catch (err) {
console.error('Verification failed:', err)
this.state = 'active'
this.render()
}
}
render() {
if (this.state === 'verified') {
this.innerHTML = this.renderVerified()
} else if (this.state === 'active' || this.state === 'uploading') {
this.innerHTML = this.renderActive()
} else if (this.state === 'expired') {
this.innerHTML = this.renderExpired()
} else {
this.innerHTML = this.renderIdle()
}
this.setupEventListeners()
}
renderIdle() {
return `
<div class="verification-widget">
<button class="verification-toggle" type="button">
<span class="verification-toggle-label">✓ ${t('verification.verify')}</span>
<span class="verification-toggle-subtitle">${t('verification.optional')}</span>
</button>
</div>
`
}
renderActive() {
const remaining = this.getRemainingTime()
const isUploading = this.state === 'uploading'
return `
<div class="verification-widget verification-widget--active">
<div class="verification-code">${this.currentCode.code}</div>
<div class="verification-timer${remaining <= 60000 ? ' verification-timer--warning' : ''}">${this.formatTime(remaining)}</div>
<p class="verification-instructions">${t('verification.instructions')}</p>
<label class="btn btn-outline verification-upload${isUploading ? ' verification-upload--loading' : ''}">
${isUploading
? `<span class="verification-spinner"></span>`
: t('verification.upload')}
<input type="file" accept="image/*" capture="environment" hidden${isUploading ? ' disabled' : ''}>
</label>
</div>
`
}
renderExpired() {
return `
<div class="verification-widget verification-widget--expired">
<p class="verification-expired-text">${t('verification.expired')}</p>
<button class="btn btn-outline verification-regenerate" type="button">✓ ${t('verification.verify')}</button>
</div>
`
}
renderVerified() {
const verifiedDate = this.getAttribute('verified-date')
const dateStr = verifiedDate
? t('verification.verifiedDate', { date: new Date(verifiedDate).toLocaleDateString() })
: ''
return `
<div class="verification-widget verification-widget--verified">
<div class="verification-success">
<svg class="verification-success-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>${t('verification.verified')}</span>
</div>
${dateStr ? `<span class="verification-date">${dateStr}</span>` : ''}
</div>
`
}
setupEventListeners() {
const toggleBtn = this.querySelector('.verification-toggle')
if (toggleBtn) {
toggleBtn.addEventListener('click', () => this.startVerification())
}
const regenerateBtn = this.querySelector('.verification-regenerate')
if (regenerateBtn) {
regenerateBtn.addEventListener('click', () => this.startVerification())
}
const fileInput = this.querySelector('input[type="file"]')
if (fileInput) {
fileInput.addEventListener('change', (e) => this.handleUpload(e))
}
}
}
customElements.define('verification-widget', VerificationWidget)
const style = document.createElement('style')
style.textContent = `
.verification-widget {
padding: var(--space-md);
border-radius: var(--radius-md);
}
.verification-toggle {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
width: 100%;
padding: var(--space-md) var(--space-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-fast);
}
.verification-toggle:hover {
border-color: var(--color-primary);
}
.verification-toggle-label {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text);
}
.verification-toggle-subtitle {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.verification-widget--active {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.verification-code {
font-family: monospace;
font-size: var(--font-size-3xl);
font-weight: 700;
letter-spacing: 0.5em;
color: var(--color-text);
text-align: center;
padding: var(--space-md) var(--space-lg);
background: var(--color-bg);
border-radius: var(--radius-md);
user-select: all;
}
.verification-timer {
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.verification-timer--warning {
color: var(--color-warning);
font-weight: 600;
}
.verification-instructions {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
margin: 0;
}
.verification-upload {
position: relative;
cursor: pointer;
}
.verification-upload--loading {
pointer-events: none;
opacity: 0.7;
}
.verification-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: verification-spin 0.8s linear infinite;
}
@keyframes verification-spin {
to { transform: rotate(360deg); }
}
.verification-widget--expired {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.verification-expired-text {
font-size: var(--font-size-sm);
color: var(--color-error);
margin: 0;
}
.verification-widget--verified {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
}
.verification-success {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-success);
font-weight: 600;
font-size: var(--font-size-base);
}
.verification-success-icon {
width: 20px;
height: 20px;
}
.verification-date {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
`
document.head.appendChild(style)
export { VerificationWidget }