feat: add reputation system with deals, ratings, level badges, and chat-widget deal confirmation
This commit is contained in:
380
docs/REPUTATION.md
Normal file
380
docs/REPUTATION.md
Normal 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 | 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"
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
208
js/services/reputation.js
Normal 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()
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user