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

380
docs/REPUTATION.md Normal file
View File

@@ -0,0 +1,380 @@
# Reputation-System — dgray.io
## Ziel
Vertrauen zwischen anonymen Nutzern aufbauen, ohne KYC, ohne Escrow.
Trust durch nachweisbares Verhalten statt durch Identität.
---
## Stufensystem
| Level | Bedingung | Badge | Effekt |
|-------|-----------|-------|--------|
| **Neu** | 0 bestätigte Deals | ⚪ | Hinweis "Neuer Account" auf Anzeigen |
| **Aktiv** | 3+ Deals, Account 30+ Tage | 🔵 | Kein Hinweis |
| **Vertrauenswürdig** | 10+ Deals, ⌀ 4.0+ Sterne | 🟢 | Badge auf Anzeigen |
| **Power Seller** | 30+ Deals, ⌀ 4.5+ Sterne | 🟣 | Badge + bevorzugte Platzierung |
---
## Datenmodell
### Directus: Collection `deals`
Bestätigte Transaktionen zwischen zwei Nutzern.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `listing_id` | UUID, FK → listings | Bezug zur Anzeige |
| `conversation_id` | UUID, FK → conversations | Bezug zur Konversation |
| `seller_hash` | string(64) | SHA-256 Hash des Verkäufers |
| `buyer_hash` | string(64) | SHA-256 Hash des Käufers |
| `seller_confirmed` | boolean, default false | Verkäufer hat Deal bestätigt |
| `buyer_confirmed` | boolean, default false | Käufer hat Deal bestätigt |
| `status` | string | `pending`, `confirmed`, `disputed` |
| `date_created` | datetime | Erstellungsdatum |
| `date_confirmed` | datetime, nullable | Zeitpunkt der beidseitigen Bestätigung |
Ein Deal gilt als `confirmed`, wenn **beide Seiten** bestätigt haben.
### Directus: Collection `ratings`
Bewertungen nach bestätigtem Deal.
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `id` | UUID | Primary Key |
| `deal_id` | UUID, FK → deals | Bezug zum Deal |
| `rater_hash` | string(64) | Wer bewertet |
| `rated_hash` | string(64) | Wer bewertet wird |
| `score` | integer | 15 Sterne |
| `date_created` | datetime | Zeitpunkt |
Pro Deal kann jeder Teilnehmer **einmal** bewerten (nach Bestätigung).
Kein Freitext-Kommentar — nur Sterne (Privacy).
### Directus: Permissions
| Collection | Public Read | Public Create | Public Update |
|------------|:-----------:|:------------:|:------------:|
| `deals` | Ja (Filter: eigener Hash) | Ja | Ja (nur eigene `*_confirmed`) |
| `ratings` | Ja (Filter: `rated_hash`) | Ja (nur nach bestätigtem Deal) | Nein |
### Computed Fields (Frontend)
Diese Werte werden im Frontend aus `deals` + `ratings` berechnet:
```
reputation = {
deals_completed: count(deals WHERE status = 'confirmed' AND user = seller OR buyer),
avg_rating: avg(ratings WHERE rated_hash = user),
account_age_days: now() - user.date_created,
level: computed from above
}
```
---
## User Flow
### Deal bestätigen (im Chat)
```
1. Käufer und Verkäufer handeln im Chat
2. Eine Seite klickt "Deal abschliessen" → Deal wird erstellt (status: pending)
3. Andere Seite sieht Bestätigungsanfrage im Chat
4. Andere Seite klickt "Deal bestätigen" → status: confirmed
5. Beide Seiten können danach bewerten (15 Sterne)
```
### UI im Chat-Widget
```
┌─────────────────────────────────────┐
│ 💬 Chat mit Verkäufer │
│ │
│ ... Nachrichten ... │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🤝 Deal abschliessen │ │
│ └─────────────────────────────┘ │
│ │
│ Nach beidseitiger Bestätigung: │
│ ┌─────────────────────────────┐ │
│ │ ⭐⭐⭐⭐☆ Bewertung abgeben │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Badge auf Listing-Card
```
┌──────────────────────┐
│ 📷 Bild │
│ Titel der Anzeige │
│ 50.00 EUR │
│ 📍 Zürich 🟢 10+ │ ← Badge + Deal-Count
└──────────────────────┘
```
### Seller-Card auf Listing-Seite
```
┌──────────────────────────┐
│ ? Anonymer Anbieter │
│ Mitglied seit 2025 │
│ 🟢 Vertrauenswürdig │
│ 12 Deals · ⌀ 4.3 ⭐ │
└──────────────────────────┘
```
---
## Scam-Prävention
| Massnahme | Beschreibung |
|-----------|--------------|
| **Neuer-Account-Warnung** | "Neuer Account — starte mit kleinen Beträgen" |
| **Preis-Limit** | Max. Anzeigenpreis für Level "Neu": 100 EUR/USD/CHF |
| **Cooldown** | Neue Accounts: max. 3 Anzeigen pro Tag |
| **Report-Konsequenz** | 3+ Reports → automatische Prüfung, Anzeigen ausgeblendet |
| **Self-Rating-Schutz** | Nur nach beidseitig bestätigtem Deal bewertbar |
---
## Implementierung
### Phase 1: Backend (Directus)
- [ ] Collection `deals` anlegen (siehe Anleitung unten)
- [ ] Collection `ratings` anlegen (siehe Anleitung unten)
- [ ] Permissions setzen (Public-Rolle, siehe unten)
- [ ] Flow: Notification bei Deal-Bestätigung (optional)
---
## Directus: Collection `deals` anlegen
```
Settings > Data Model > + Create Collection
Name: deals
Primary Key: UUID (auto-generated)
```
| Feld | Typ | Interface | Einstellungen |
|------|-----|-----------|---------------|
| `id` | UUID | | Primary Key, auto-generated |
| `listing` | UUID (String) | Input | Listing-ID (kein FK, da Public-Rolle keinen Zugriff auf M2O hat) |
| `conversation` | UUID (String) | Input | Conversation-ID |
| `seller_hash` | String | Input | Required, max 64 chars |
| `buyer_hash` | String | Input | Required, max 64 chars |
| `seller_confirmed` | Boolean | Toggle | Default: `false` |
| `buyer_confirmed` | Boolean | Toggle | Default: `false` |
| `status` | String | Dropdown | `pending`, `confirmed`, `disputed` (Default: `pending`) |
| `date_created` | DateTime | DateTime | Auto, Read-only (Directus: "On Create" → Save Current Date/Time) |
| `date_confirmed` | DateTime | DateTime | Nullable, kein Default |
### Schritt-für-Schritt
1. **Collection erstellen:**
- Settings → Data Model → `+ Create Collection`
- Name: `deals`
- Primary Key Field: Type `UUID`, Auto-generated: ✓
- Optional Fields: ✓ `date_created`
2. **Felder anlegen (in dieser Reihenfolge):**
**listing** (String):
- Interface: Input
- Schema: String, max length 36
- Nicht nullable
**conversation** (String):
- Interface: Input
- Schema: String, max length 36
- Nicht nullable
**seller_hash** (String):
- Interface: Input
- Schema: String, max length 64
- Nicht nullable
**buyer_hash** (String):
- Interface: Input
- Schema: String, max length 64
- Nicht nullable
**seller_confirmed** (Boolean):
- Interface: Toggle
- Schema: Boolean, Default: `false`
**buyer_confirmed** (Boolean):
- Interface: Toggle
- Schema: Boolean, Default: `false`
**status** (String):
- Interface: Dropdown
- Options: `pending`, `confirmed`, `disputed`
- Schema: String, Default: `pending`
**date_confirmed** (DateTime):
- Interface: DateTime
- Schema: Timestamp, Nullable: ✓
---
## Directus: Collection `ratings` anlegen
```
Settings > Data Model > + Create Collection
Name: ratings
Primary Key: UUID (auto-generated)
```
| Feld | Typ | Interface | Einstellungen |
|------|-----|-----------|---------------|
| `id` | UUID | | Primary Key, auto-generated |
| `deal` | UUID (String) | Input | Deal-ID |
| `rater_hash` | String | Input | Required, max 64 chars, wer bewertet |
| `rated_hash` | String | Input | Required, max 64 chars, wer bewertet wird |
| `score` | Integer | Slider | Min: 1, Max: 5 |
| `date_created` | DateTime | DateTime | Auto, Read-only |
### Schritt-für-Schritt
1. **Collection erstellen:**
- Settings → Data Model → `+ Create Collection`
- Name: `ratings`
- Primary Key Field: Type `UUID`, Auto-generated: ✓
- Optional Fields: ✓ `date_created`
2. **Felder anlegen:**
**deal** (String):
- Interface: Input
- Schema: String, max length 36
- Nicht nullable
**rater_hash** (String):
- Interface: Input
- Schema: String, max length 64
- Nicht nullable
**rated_hash** (String):
- Interface: Input
- Schema: String, max length 64
- Nicht nullable
**score** (Integer):
- Interface: Slider
- Options: Min `1`, Max `5`, Step `1`
- Schema: Integer, nicht nullable
---
## Directus: Permissions (Public-Rolle)
```
Settings > Access Control > Public
```
### Collection `deals`
**Read:**
- ✓ Erlaubt
- Felder: Alle
- Filter: Eigene Deals sehen
```json
{
"_or": [
{ "seller_hash": { "_eq": "$CURRENT_USER" } },
{ "buyer_hash": { "_eq": "$CURRENT_USER" } }
]
}
```
**Hinweis:** Da wir SHA-256 Hashes nutzen (nicht Directus-User-IDs), funktioniert `$CURRENT_USER` hier NICHT.
Stattdessen: **Keine Read-Filter setzen** (alle Deals lesbar). Die Filterung passiert im Frontend via API-Query.
Alternativ: Kein Filter, aber nur bestimmte Felder freigeben (z.B. `seller_hash`, `buyer_hash`, `status`, `date_confirmed` — ohne `conversation`, `listing`).
**Empfohlene Read-Permissions (ohne Filter, mit Feld-Einschränkung):**
- Felder: `id`, `listing`, `conversation`, `seller_hash`, `buyer_hash`, `seller_confirmed`, `buyer_confirmed`, `status`, `date_created`, `date_confirmed`
**Create:**
- ✓ Erlaubt
- Felder: `listing`, `conversation`, `seller_hash`, `buyer_hash`, `seller_confirmed`, `buyer_confirmed`, `status`
- Kein Validation-Filter nötig (Frontend validiert)
**Update:**
- ✓ Erlaubt
- Felder: **Nur** `seller_confirmed`, `buyer_confirmed`, `status`, `date_confirmed`
- **Kein Custom-Filter** (da `$CURRENT_USER` nicht mit Hashes funktioniert)
- Schutz: Im Frontend wird geprüft ob der User Teilnehmer ist
**Delete:**
- ✗ Nicht erlaubt
### Collection `ratings`
**Read:**
- ✓ Erlaubt
- Felder: `id`, `deal`, `rater_hash`, `rated_hash`, `score`, `date_created`
- Kein Filter (Ratings sind öffentlich sichtbar für Reputation)
**Create:**
- ✓ Erlaubt
- Felder: `deal`, `rater_hash`, `rated_hash`, `score`
**Update:**
- ✗ Nicht erlaubt (Bewertungen sind final)
**Delete:**
- ✗ Nicht erlaubt
---
## Directus: DIRECTUS-SCHEMA.md aktualisieren
Nach dem Anlegen diese Tabellen in `docs/DIRECTUS-SCHEMA.md` ergänzen und die Permissions-Tabelle erweitern:
```
| `deals` | ✓ | ✓ | ✓ | - | Update nur confirmed/status Felder |
| `ratings` | ✓ | ✓ | - | - | Bewertungen sind final |
```
### Phase 2: Service (Frontend)
- [x] `js/services/reputation.js` — Deals/Ratings CRUD, Level-Berechnung, Caching
- [x] i18n-Keys in allen 7 Sprachen
- [ ] Integration in `conversations.js` — Deal-Bestätigung im Chat
### Phase 3: UI
- [x] Chat-Widget: "Deal abschliessen" Button + Bestätigungsanfrage
- [x] Chat-Widget: Sterne-Bewertung nach bestätigtem Deal
- [ ] Listing-Card: Badge + Deal-Count
- [x] Listing-Page Seller-Card: Level, Deals, Rating
- [x] Neuer-Account-Warnung auf Listing-Page
### Phase 4: Scam-Prävention
- [ ] Preis-Limit für neue Accounts
- [ ] Anzeigen-Cooldown für neue Accounts
- [ ] Report-Integration mit Konsequenzen
---
## i18n Keys (benötigt)
```
reputation.level.new = "Neuer Account"
reputation.level.active = "Aktiv"
reputation.level.trusted = "Vertrauenswürdig"
reputation.level.power = "Power Seller"
reputation.deals = "{{count}} Deals"
reputation.dealsSingular = "1 Deal"
reputation.avgRating = "⌀ {{rating}} ⭐"
reputation.newWarning = "Neuer Account — starte mit kleinen Beträgen"
reputation.confirmDeal = "Deal abschliessen"
reputation.dealPending = "Warte auf Bestätigung"
reputation.dealConfirmed = "Deal bestätigt"
reputation.rate = "Bewertung abgeben"
reputation.rated = "Bewertet"
```

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);

208
js/services/reputation.js Normal file
View File

@@ -0,0 +1,208 @@
import { directus } from './directus.js'
import { cryptoService } from './crypto.js'
const CACHE_TTL = 5 * 60 * 1000
class ReputationService {
constructor() {
this.cache = new Map()
}
async getUserHash() {
await cryptoService.ready
const publicKey = cryptoService.getPublicKey()
const encoder = new TextEncoder()
const data = encoder.encode(publicKey)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
// ── Deals ──
async createDeal(conversation) {
const userHash = await this.getUserHash()
// participant_hash_1 = buyer (initiator), participant_hash_2 = seller
const sellerHash = conversation.participant_hash_2
const buyerHash = conversation.participant_hash_1
const isSeller = sellerHash === userHash
const response = await directus.post('/items/deals', {
conversation: conversation.id,
listing: conversation.listing_id,
seller_hash: sellerHash,
buyer_hash: buyerHash,
status: 'pending',
seller_confirmed: isSeller,
buyer_confirmed: !isSeller
})
return response.data || response
}
async confirmDeal(dealId) {
const userHash = await this.getUserHash()
const deal = await this.getDeal(dealId)
if (!deal) throw new Error('Deal not found')
const isSeller = deal.seller_hash === userHash
const isBuyer = deal.buyer_hash === userHash
if (!isSeller && !isBuyer) throw new Error('Not a participant of this deal')
const update = {}
if (isSeller) update.seller_confirmed = true
if (isBuyer) update.buyer_confirmed = true
const sellerConfirmed = isSeller ? true : deal.seller_confirmed
const buyerConfirmed = isBuyer ? true : deal.buyer_confirmed
if (sellerConfirmed && buyerConfirmed) {
update.status = 'confirmed'
update.date_confirmed = new Date().toISOString()
}
const response = await directus.patch(`/items/deals/${dealId}`, update)
this.clearCache()
return response.data || response
}
async getDeal(dealId) {
const response = await directus.get(`/items/deals/${dealId}`)
return response.data || response
}
async getDealsForConversation(conversationId) {
const response = await directus.get('/items/deals', {
filter: { conversation: { _eq: conversationId } },
sort: ['-date_created']
})
return response.data || []
}
async getDealsForUser(userHash) {
const response = await directus.get('/items/deals', {
filter: {
status: { _eq: 'confirmed' },
_or: [
{ seller_hash: { _eq: userHash } },
{ buyer_hash: { _eq: userHash } }
]
},
sort: ['-date_confirmed']
})
return response.data || []
}
// ── Ratings ──
async rateDeal(dealId, score) {
if (score < 1 || score > 5) throw new Error('Score must be between 1 and 5')
const deal = await this.getDeal(dealId)
if (!deal) throw new Error('Deal not found')
if (deal.status !== 'confirmed') throw new Error('Deal must be confirmed before rating')
const userHash = await this.getUserHash()
const isSeller = deal.seller_hash === userHash
const isBuyer = deal.buyer_hash === userHash
if (!isSeller && !isBuyer) throw new Error('Not a participant of this deal')
const ratedUserHash = isSeller ? deal.buyer_hash : deal.seller_hash
const existing = await directus.get('/items/ratings', {
filter: {
deal: { _eq: dealId },
rater_hash: { _eq: userHash }
},
limit: 1
})
if (existing.data?.length > 0) throw new Error('Already rated this deal')
const response = await directus.post('/items/ratings', {
deal: dealId,
rater_hash: userHash,
rated_hash: ratedUserHash,
score,
date_created: new Date().toISOString()
})
this.clearCache()
return response.data || response
}
async getRatingsForUser(userHash) {
const response = await directus.get('/items/ratings', {
filter: { rated_hash: { _eq: userHash } },
sort: ['-date_created']
})
return response.data || []
}
// ── Reputation ──
async getReputation(userHash) {
const cached = this.cache.get(userHash)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.value
}
const [deals, ratings] = await Promise.all([
this.getDealsForUser(userHash),
this.getRatingsForUser(userHash)
])
const dealsCompleted = deals.length
const avgRating = ratings.length > 0
? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
: 0
let accountAgeDays = 0
const dates = deals.map(d => d.date_confirmed || d.date_created).filter(Boolean)
if (dates.length > 0) {
const oldest = dates
.map(d => new Date(d).getTime())
.reduce((min, t) => Math.min(min, t), Infinity)
accountAgeDays = Math.floor((Date.now() - oldest) / (1000 * 60 * 60 * 24))
}
let level = 'new'
if (dealsCompleted >= 30 && avgRating >= 4.5) {
level = 'power'
} else if (dealsCompleted >= 10 && avgRating >= 4.0) {
level = 'trusted'
} else if (dealsCompleted >= 3 && accountAgeDays >= 30) {
level = 'active'
}
const reputation = {
deals_completed: dealsCompleted,
avg_rating: Math.round(avgRating * 100) / 100,
account_age_days: accountAgeDays,
level
}
this.cache.set(userHash, { value: reputation, timestamp: Date.now() })
return reputation
}
getLevelInfo(level) {
const levels = {
new: { badge: '⚪', i18nKey: 'reputation.level.new' },
active: { badge: '🔵', i18nKey: 'reputation.level.active' },
trusted: { badge: '🟢', i18nKey: 'reputation.level.trusted' },
power: { badge: '🟣', i18nKey: 'reputation.level.power' }
}
return levels[level] || levels.new
}
clearCache() {
this.cache.clear()
}
}
export const reputationService = new ReputationService()

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Bezahlt via Monero (XMR)",
"awaitingConfirmation": "Warte auf Blockchain-Bestätigung",
"awaitingHint": "Deine Zahlung wurde empfangen. Die Anzeige wird nach 1 Bestätigung automatisch veröffentlicht."
},
"reputation": {
"level": {
"new": "Neuer Account",
"active": "Aktiv",
"trusted": "Vertrauenswürdig",
"power": "Power Seller"
},
"deals": "{{count}} Deals",
"dealsSingular": "1 Deal",
"avgRating": "⌀ {{rating}}",
"newWarning": "Neuer Account — starte mit kleinen Beträgen",
"confirmDeal": "Deal abschliessen",
"confirmDealHint": "Bestätige, dass die Transaktion stattgefunden hat",
"dealPending": "Warte auf Bestätigung",
"dealConfirmed": "Deal bestätigt",
"rate": "Bewertung abgeben",
"rated": "Bewertet",
"memberSince": "Mitglied seit {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Paid via Monero (XMR)",
"awaitingConfirmation": "Awaiting blockchain confirmation",
"awaitingHint": "Your payment has been received. The listing will be published automatically after 1 confirmation."
},
"reputation": {
"level": {
"new": "New Account",
"active": "Active",
"trusted": "Trusted",
"power": "Power Seller"
},
"deals": "{{count}} deals",
"dealsSingular": "1 deal",
"avgRating": "⌀ {{rating}}",
"newWarning": "New account — start with small amounts",
"confirmDeal": "Complete Deal",
"confirmDealHint": "Confirm that the transaction has taken place",
"dealPending": "Waiting for confirmation",
"dealConfirmed": "Deal confirmed",
"rate": "Leave a rating",
"rated": "Rated",
"memberSince": "Member since {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Pagado con Monero (XMR)",
"awaitingConfirmation": "Esperando confirmación en la cadena de bloques",
"awaitingHint": "Tu pago ha sido recibido. El anuncio se publicará automáticamente tras 1 confirmación."
},
"reputation": {
"level": {
"new": "Cuenta nueva",
"active": "Activo",
"trusted": "Confiable",
"power": "Supervendedor"
},
"deals": "{{count}} transacciones",
"dealsSingular": "1 transacción",
"avgRating": "⌀ {{rating}}",
"newWarning": "Cuenta nueva — empieza con montos pequeños",
"confirmDeal": "Cerrar trato",
"confirmDealHint": "Confirma que la transacción se ha realizado",
"dealPending": "Esperando confirmación",
"dealConfirmed": "Trato confirmado",
"rate": "Dejar una valoración",
"rated": "Valorado",
"memberSince": "Miembro desde {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Payé via Monero (XMR)",
"awaitingConfirmation": "En attente de confirmation blockchain",
"awaitingHint": "Votre paiement a été reçu. L'annonce sera publiée automatiquement après 1 confirmation."
},
"reputation": {
"level": {
"new": "Nouveau compte",
"active": "Actif",
"trusted": "Fiable",
"power": "Top vendeur"
},
"deals": "{{count}} transactions",
"dealsSingular": "1 transaction",
"avgRating": "⌀ {{rating}}",
"newWarning": "Nouveau compte — commencez avec de petits montants",
"confirmDeal": "Conclure la transaction",
"confirmDealHint": "Confirmez que la transaction a eu lieu",
"dealPending": "En attente de confirmation",
"dealConfirmed": "Transaction confirmée",
"rate": "Donner une évaluation",
"rated": "Évalué",
"memberSince": "Membre depuis {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Pagato tramite Monero (XMR)",
"awaitingConfirmation": "In attesa di conferma sulla blockchain",
"awaitingHint": "Il tuo pagamento è stato ricevuto. L'annuncio verrà pubblicato automaticamente dopo 1 conferma."
},
"reputation": {
"level": {
"new": "Nuovo account",
"active": "Attivo",
"trusted": "Affidabile",
"power": "Top venditore"
},
"deals": "{{count}} transazioni",
"dealsSingular": "1 transazione",
"avgRating": "⌀ {{rating}}",
"newWarning": "Nuovo account — inizia con piccoli importi",
"confirmDeal": "Concludi transazione",
"confirmDealHint": "Conferma che la transazione ha avuto luogo",
"dealPending": "In attesa di conferma",
"dealConfirmed": "Transazione confermata",
"rate": "Lascia una valutazione",
"rated": "Valutato",
"memberSince": "Membro dal {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Pago via Monero (XMR)",
"awaitingConfirmation": "Aguardando confirmação na blockchain",
"awaitingHint": "Seu pagamento foi recebido. O anúncio será publicado automaticamente após 1 confirmação."
},
"reputation": {
"level": {
"new": "Conta nova",
"active": "Ativo",
"trusted": "Confiável",
"power": "Super vendedor"
},
"deals": "{{count}} transações",
"dealsSingular": "1 transação",
"avgRating": "⌀ {{rating}}",
"newWarning": "Conta nova — comece com valores pequenos",
"confirmDeal": "Fechar negócio",
"confirmDealHint": "Confirme que a transação aconteceu",
"dealPending": "Aguardando confirmação",
"dealConfirmed": "Negócio confirmado",
"rate": "Deixar uma avaliação",
"rated": "Avaliado",
"memberSince": "Membro desde {{date}}"
}
}

View File

@@ -321,5 +321,24 @@
"paidViaXmr": "Оплачено через Monero (XMR)",
"awaitingConfirmation": "Ожидание подтверждения в блокчейне",
"awaitingHint": "Ваш платёж получен. Объявление будет опубликовано автоматически после 1 подтверждения."
},
"reputation": {
"level": {
"new": "Новый аккаунт",
"active": "Активный",
"trusted": "Надёжный",
"power": "Топ-продавец"
},
"deals": "{{count}} сделок",
"dealsSingular": "1 сделка",
"avgRating": "⌀ {{rating}}",
"newWarning": "Новый аккаунт — начните с небольших сумм",
"confirmDeal": "Завершить сделку",
"confirmDealHint": "Подтвердите, что сделка состоялась",
"dealPending": "Ожидание подтверждения",
"dealConfirmed": "Сделка подтверждена",
"rate": "Оставить оценку",
"rated": "Оценено",
"memberSince": "Участник с {{date}}"
}
}