From 83f1303d13d81ed19d9bb883f920c9c4d7f77ed1 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Mon, 9 Feb 2026 17:46:49 +0100 Subject: [PATCH] feat: add reputation system with deals, ratings, level badges, and chat-widget deal confirmation --- docs/REPUTATION.md | 380 ++++++++++++++++++++++++++++ js/components/chat-widget.js | 250 ++++++++++++++++++ js/components/pages/page-listing.js | 101 ++++++++ js/services/reputation.js | 208 +++++++++++++++ locales/de.json | 19 ++ locales/en.json | 19 ++ locales/es.json | 19 ++ locales/fr.json | 19 ++ locales/it.json | 19 ++ locales/pt.json | 19 ++ locales/ru.json | 19 ++ 11 files changed, 1072 insertions(+) create mode 100644 docs/REPUTATION.md create mode 100644 js/services/reputation.js diff --git a/docs/REPUTATION.md b/docs/REPUTATION.md new file mode 100644 index 0000000..563c45c --- /dev/null +++ b/docs/REPUTATION.md @@ -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 | 1–5 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 (1–5 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" +``` diff --git a/js/components/chat-widget.js b/js/components/chat-widget.js index 1bf8045..1386c7e 100644 --- a/js/components/chat-widget.js +++ b/js/components/chat-widget.js @@ -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()} + ${this.renderDealSection()} +
+ + + ` + } + + 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 */` +
+
+ + ${t('reputation.dealPending')} +
+
+ ` + } + return /* html */` +
+ + ${t('reputation.confirmDealHint')} +
+ ` + } + + if (this.deal.status === 'confirmed') { + if (this.hasRated) { + return /* html */` +
+
+ ✓ ${t('reputation.dealConfirmed')} · ${t('reputation.rated')} +
+
+ ` + } + return /* html */` +
+
✓ ${t('reputation.dealConfirmed')}
+
+ ${t('reputation.rate')} +
+ ${[1,2,3,4,5].map(s => ``).join('')} +
+
+
+ ` + } + + 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) diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index ed5edad..dc8ac5d 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -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 { ${t('listing.memberSince')} 2024 + ${this.renderSellerReputation()} ` } + 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) : '' @@ -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); diff --git a/js/services/reputation.js b/js/services/reputation.js new file mode 100644 index 0000000..cdc8b7e --- /dev/null +++ b/js/services/reputation.js @@ -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() diff --git a/locales/de.json b/locales/de.json index f8a31bc..f348694 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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}}" } } diff --git a/locales/en.json b/locales/en.json index 6859fa4..fffde4a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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}}" } } diff --git a/locales/es.json b/locales/es.json index 80edbf9..e9de84d 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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}}" } } diff --git a/locales/fr.json b/locales/fr.json index faca4c5..f2fc58e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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}}" } } diff --git a/locales/it.json b/locales/it.json index 2121d49..6021cbd 100644 --- a/locales/it.json +++ b/locales/it.json @@ -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}}" } } diff --git a/locales/pt.json b/locales/pt.json index 52e0e15..e8cbb1b 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -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}}" } } diff --git a/locales/ru.json b/locales/ru.json index 0793225..5e9dfc1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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}}" } }