feat: add reputation system with deals, ratings, level badges, and chat-widget deal confirmation

This commit is contained in:
2026-02-09 17:46:49 +01:00
parent 2db3e56f00
commit 83f1303d13
11 changed files with 1072 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import { t, i18n } from '../i18n.js'
import { conversationsService } from '../services/conversations.js'
import { cryptoService } from '../services/crypto.js'
import { escapeHTML } from '../utils/helpers.js'
import { reputationService } from '../services/reputation.js'
class ChatWidget extends HTMLElement {
static get observedAttributes() {
@@ -21,6 +22,8 @@ class ChatWidget extends HTMLElement {
this.loading = true
this.error = null
this._initialized = false
this.deal = null
this.hasRated = false
}
connectedCallback() {
@@ -63,6 +66,7 @@ class ChatWidget extends HTMLElement {
try {
this.conversation = await conversationsService.startOrFindByListing(this.listingId)
await this.loadMessages()
await this.loadDealState()
} catch (e) {
console.error('Failed to init conversation:', e)
this.error = 'init-failed'
@@ -86,6 +90,24 @@ class ChatWidget extends HTMLElement {
this.scrollToBottom()
}
async loadDealState() {
if (!this.conversation) return
try {
const deals = await reputationService.getDealsForConversation(this.conversation.id)
this.deal = deals[0] || null
this._cachedUserHash = await reputationService.getUserHash()
if (this.deal && this.deal.status === 'confirmed') {
const userHash = this._cachedUserHash
const ratings = await reputationService.getRatingsForUser(
this.deal.seller_hash === userHash ? this.deal.buyer_hash : this.deal.seller_hash
)
this.hasRated = ratings.some(r => r.deal === this.deal.id && r.rater_hash === userHash)
}
} catch (e) {
console.error('Failed to load deal state:', e)
}
}
render() {
if (this.loading) {
this.innerHTML = /* html */`
@@ -124,6 +146,8 @@ class ChatWidget extends HTMLElement {
: this.renderMessagesHtml()}
</div>
${this.renderDealSection()}
<form class="chat-input" id="chat-form">
<input
type="text"
@@ -172,9 +196,89 @@ class ChatWidget extends HTMLElement {
}
}
renderDealSection() {
if (!this.conversation || !this.conversation.otherPublicKey) return ''
if (!this.deal) {
return /* html */`
<div class="deal-section">
<button class="deal-btn" id="deal-create-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
${t('reputation.confirmDeal')}
</button>
</div>
`
}
if (this.deal.status === 'pending') {
const userHash = this._cachedUserHash
const iAmConfirmed = (this.deal.seller_hash === userHash && this.deal.seller_confirmed)
|| (this.deal.buyer_hash === userHash && this.deal.buyer_confirmed)
if (iAmConfirmed) {
return /* html */`
<div class="deal-section">
<div class="deal-status deal-pending">
<span class="pulse-dot"></span>
${t('reputation.dealPending')}
</div>
</div>
`
}
return /* html */`
<div class="deal-section">
<button class="deal-btn deal-confirm" id="deal-confirm-btn">
${t('reputation.confirmDeal')}
</button>
<span class="deal-hint">${t('reputation.confirmDealHint')}</span>
</div>
`
}
if (this.deal.status === 'confirmed') {
if (this.hasRated) {
return /* html */`
<div class="deal-section">
<div class="deal-status deal-confirmed">
${t('reputation.dealConfirmed')} · ${t('reputation.rated')}
</div>
</div>
`
}
return /* html */`
<div class="deal-section">
<div class="deal-status deal-confirmed">✓ ${t('reputation.dealConfirmed')}</div>
<div class="deal-rating" id="deal-rating">
<span class="rating-label">${t('reputation.rate')}</span>
<div class="rating-stars">
${[1,2,3,4,5].map(s => `<button class="star-btn" data-score="${s}" aria-label="${s}">★</button>`).join('')}
</div>
</div>
</div>
`
}
return ''
}
setupEventListeners() {
const form = this.querySelector('#chat-form')
form?.addEventListener('submit', (e) => this.handleSubmit(e))
const dealCreateBtn = this.querySelector('#deal-create-btn')
dealCreateBtn?.addEventListener('click', () => this.handleCreateDeal())
const dealConfirmBtn = this.querySelector('#deal-confirm-btn')
dealConfirmBtn?.addEventListener('click', () => this.handleConfirmDeal())
this.querySelectorAll('.star-btn').forEach(btn => {
btn.addEventListener('click', () => this.handleRate(parseInt(btn.dataset.score)))
})
}
async handleSubmit(e) {
@@ -196,6 +300,38 @@ class ChatWidget extends HTMLElement {
await this.refreshMessages()
}
async handleCreateDeal() {
if (!this.conversation) return
try {
this._cachedUserHash = await reputationService.getUserHash()
this.deal = await reputationService.createDeal(this.conversation)
this.render()
} catch (e) {
console.error('Failed to create deal:', e)
}
}
async handleConfirmDeal() {
if (!this.deal) return
try {
this.deal = await reputationService.confirmDeal(this.deal.id)
this.render()
} catch (e) {
console.error('Failed to confirm deal:', e)
}
}
async handleRate(score) {
if (!this.deal) return
try {
await reputationService.rateDeal(this.deal.id, score)
this.hasRated = true
this.render()
} catch (e) {
console.error('Failed to rate:', e)
}
}
scrollToBottom() {
const container = this.querySelector('#chat-messages')
if (container) {
@@ -357,6 +493,120 @@ style.textContent = /* css */`
chat-widget .chat-input button:hover {
background: var(--color-text-secondary);
}
chat-widget .deal-section {
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
chat-widget .deal-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.2s;
}
chat-widget .deal-btn:hover {
border-color: var(--color-text-muted);
color: var(--color-text);
}
chat-widget .deal-btn.deal-confirm {
background: var(--color-text);
color: var(--color-bg);
border-color: var(--color-text);
}
chat-widget .deal-btn.deal-confirm:hover {
opacity: 0.8;
}
chat-widget .deal-hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
chat-widget .deal-status {
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--space-xs);
}
chat-widget .deal-pending {
color: var(--color-text-muted);
}
chat-widget .deal-confirmed {
color: var(--color-text-secondary);
}
chat-widget .deal-rating {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-left: auto;
}
chat-widget .rating-label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
chat-widget .rating-stars {
display: flex;
gap: 2px;
}
chat-widget .star-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-lg);
color: var(--color-border);
padding: 0;
line-height: 1;
transition: color 0.15s;
}
chat-widget .star-btn:hover,
chat-widget .star-btn:hover ~ .star-btn {
color: var(--color-text-muted);
}
chat-widget .rating-stars:hover .star-btn {
color: var(--color-text);
}
chat-widget .rating-stars .star-btn:hover ~ .star-btn {
color: var(--color-border);
}
chat-widget .pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-muted);
display: inline-block;
animation: pulse-deal 1.5s infinite;
}
@keyframes pulse-deal {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
`
document.head.appendChild(style)

View File

@@ -9,6 +9,7 @@ import '../chat-widget.js'
import '../location-map.js'
import '../listing-card.js'
import { categoriesService } from '../../services/categories.js'
import { reputationService } from '../../services/reputation.js'
class PageListing extends HTMLElement {
constructor() {
@@ -20,6 +21,7 @@ class PageListing extends HTMLElement {
this.rates = null
this.isOwner = false
this.hasPendingChats = false
this.sellerReputation = null
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
}
@@ -69,6 +71,8 @@ class PageListing extends HTMLElement {
if (this.listing?.user_created) {
await this.loadSellerListings()
}
await this.loadSellerReputation()
} catch (e) {
console.error('Failed to load listing:', e)
this.listing = null
@@ -179,6 +183,23 @@ class PageListing extends HTMLElement {
}
}
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)
}
@@ -443,10 +464,44 @@ class PageListing extends HTMLElement {
<span>${t('listing.memberSince')} 2024</span>
</div>
</div>
${this.renderSellerReputation()}
</div>
`
}
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 */`<span class="seller-rating" title="${t('reputation.avgRating', { rating: rep.avg_rating.toFixed(1) })}">${stars}</span>`
}
const showWarning = rep.level === 'new'
return /* html */`
<div class="seller-reputation">
<div class="seller-level">
<span class="level-badge">${levelInfo.badge}</span>
<span class="level-name">${t(levelInfo.i18nKey)}</span>
${dealCount > 0 ? `<span class="deal-count">· ${dealText}</span>` : ''}
</div>
${ratingHtml}
</div>
${showWarning ? /* html */`
<div class="seller-warning">
${t('reputation.newWarning')}
</div>
` : ''}
`
}
renderListingCard(listing) {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
@@ -961,6 +1016,52 @@ style.textContent = /* css */`
color: var(--color-text-muted);
}
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);