feat: add verifiable listings (proof of possession) with verification widget, badge on cards/detail, i18n (7 langs), fix edit prefill for location/monero, prevent edit/delete on pending listings
This commit is contained in:
@@ -179,7 +179,7 @@ locales/
|
|||||||
14. Tor Hidden Service (.onion URL)
|
14. Tor Hidden Service (.onion URL)
|
||||||
15. Öffentliche Statistiken (Anzeigen, User, Deals — erst ab Schwellwert anzeigen)
|
15. Öffentliche Statistiken (Anzeigen, User, Deals — erst ab Schwellwert anzeigen)
|
||||||
16. ~~Pseudonyme & Avatare~~ ✅ `identity.js` — deterministisch aus User-ID, Identicon + Name (Adjektiv+Tier+Zahl)
|
16. ~~Pseudonyme & Avatare~~ ✅ `identity.js` — deterministisch aus User-ID, Identicon + Name (Adjektiv+Tier+Zahl)
|
||||||
17. Verifiable Listings (siehe `docs/KILLER-FEATURES.md`)
|
17. ~~Verifiable Listings~~ ✅ `verification.js` Service, `verification-widget.js` Component, Badge auf Cards + Listing-Detail
|
||||||
18. Selbstzerstörende Listings (siehe `docs/KILLER-FEATURES.md`)
|
18. Selbstzerstörende Listings (siehe `docs/KILLER-FEATURES.md`)
|
||||||
19. Blind Meeting Points (siehe `docs/KILLER-FEATURES.md`)
|
19. Blind Meeting Points (siehe `docs/KILLER-FEATURES.md`)
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ locales/
|
|||||||
|
|
||||||
| Collection | Read | Create | Update | Hinweise |
|
| Collection | Read | Create | Update | Hinweise |
|
||||||
|------------|------|--------|--------|----------|
|
|------------|------|--------|--------|----------|
|
||||||
| `listings` | ✓ | ✓ | ✓ | Nur `status=published` lesen, Update nur `views` (Public) |
|
| `listings` | ✓ | ✓ | ✓ | Nur `status=published` lesen, Update: `views` (Public), `verified`/`verification_*` (User) |
|
||||||
| `listings_files` | ✓ | ✓ | - | Junction-Table für Bilder |
|
| `listings_files` | ✓ | ✓ | - | Junction-Table für Bilder |
|
||||||
| `directus_files` | ✓ | ✓ | - | Für Assets/Bilder |
|
| `directus_files` | ✓ | ✓ | - | Für Assets/Bilder |
|
||||||
| `categories` | ✓ | - | - | Nur `status=published` |
|
| `categories` | ✓ | - | - | Nur `status=published` |
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -23,11 +23,13 @@ kashilo.com ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstle
|
|||||||
| Anonyme Nutzung (UUID + Hash) | Mittel | ✅ Fertig |
|
| Anonyme Nutzung (UUID + Hash) | Mittel | ✅ Fertig |
|
||||||
| PWA | Mittel | ✅ Grundgerüst |
|
| PWA | Mittel | ✅ Grundgerüst |
|
||||||
| Light/Dark Mode | Niedrig | ✅ Fertig |
|
| Light/Dark Mode | Niedrig | ✅ Fertig |
|
||||||
| i18n (DE/EN/FR) | Niedrig | ✅ Fertig |
|
| i18n (7 Sprachen) | Niedrig | ✅ Fertig |
|
||||||
| Bildergalerie | Niedrig | ✅ Fertig |
|
| Bildergalerie | Niedrig | ✅ Fertig |
|
||||||
| E2E-Chat (NaCl box.before + secretbox) | Hoch | ✅ Fertig |
|
| E2E-Chat (NaCl box.before + secretbox) | Hoch | ✅ Fertig |
|
||||||
| PoW Captcha (Server-seitig) | Mittel | ✅ Fertig |
|
| PoW Captcha (Server-seitig) | Mittel | ✅ Fertig |
|
||||||
| Rating-System | Mittel | 🔲 Offen |
|
| Rating-System | Mittel | ✅ Fertig |
|
||||||
|
| Verifiable Listings | Mittel | ✅ Fertig |
|
||||||
|
| Reputation-System | Mittel | ✅ Fertig |
|
||||||
| 2FA | Mittel | 🔲 Offen |
|
| 2FA | Mittel | 🔲 Offen |
|
||||||
|
|
||||||
### ⚠️ Kritische Punkte
|
### ⚠️ Kritische Punkte
|
||||||
@@ -177,7 +179,10 @@ kashilo/
|
|||||||
│ │ ├── pow-captcha.js # PoW Captcha (Server-first, lokaler Fallback)
|
│ │ ├── pow-captcha.js # PoW Captcha (Server-first, lokaler Fallback)
|
||||||
│ │ ├── btcpay.js # BTCPay Server Integration (Invoice, Checkout)
|
│ │ ├── btcpay.js # BTCPay Server Integration (Invoice, Checkout)
|
||||||
│ │ ├── favorites.js # Favoriten (localStorage + Directus Sync)
|
│ │ ├── favorites.js # Favoriten (localStorage + Directus Sync)
|
||||||
│ │ └── notifications.js# Benachrichtigungen (Polling, Badge)
|
│ │ ├── notifications.js# Benachrichtigungen (Polling, Badge)
|
||||||
|
│ │ ├── reputation.js # Reputation (Deals, Ratings, Level)
|
||||||
|
│ │ ├── verification.js # Verifiable Listings (Proof of Possession)
|
||||||
|
│ │ └── identity.js # Pseudonyme & Identicon-Avatare
|
||||||
│ ├── vendor/
|
│ ├── vendor/
|
||||||
│ │ ├── nacl-fast.min.js # TweetNaCl (self-hosted)
|
│ │ ├── nacl-fast.min.js # TweetNaCl (self-hosted)
|
||||||
│ │ ├── nacl-util.min.js # TweetNaCl Utils
|
│ │ ├── nacl-util.min.js # TweetNaCl Utils
|
||||||
@@ -192,7 +197,11 @@ kashilo/
|
|||||||
├── locales/
|
├── locales/
|
||||||
│ ├── de.json # Deutsch
|
│ ├── de.json # Deutsch
|
||||||
│ ├── en.json # English
|
│ ├── en.json # English
|
||||||
│ └── fr.json # Français
|
│ ├── fr.json # Français
|
||||||
|
│ ├── it.json # Italiano
|
||||||
|
│ ├── es.json # Español
|
||||||
|
│ ├── pt.json # Português (BR)
|
||||||
|
│ └── ru.json # Русский
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── index.html # Test-Runner UI
|
│ ├── index.html # Test-Runner UI
|
||||||
│ ├── test-runner.js # Test-Framework
|
│ ├── test-runner.js # Test-Framework
|
||||||
@@ -252,7 +261,10 @@ kashilo/
|
|||||||
- [x] Expired Listings (Directus Flow, Status-Badges auf Cards)
|
- [x] Expired Listings (Directus Flow, Status-Badges auf Cards)
|
||||||
- [x] Token-Refresh bei Tab-Visibility-Change
|
- [x] Token-Refresh bei Tab-Visibility-Change
|
||||||
### Phase 5: Trust & Safety
|
### Phase 5: Trust & Safety
|
||||||
- [ ] Rating-System
|
- [x] Rating-System
|
||||||
|
- [x] Reputation-System (Deals, Levels, Badges)
|
||||||
|
- [x] Verifiable Listings (Proof of Possession)
|
||||||
|
- [x] Pseudonyme & Identicon-Avatare
|
||||||
- [ ] 2FA
|
- [ ] 2FA
|
||||||
- [ ] Reporting/Moderation
|
- [ ] Reporting/Moderation
|
||||||
- [x] AGB, Datenschutz, Impressum (Entwürfe in 7 Sprachen)
|
- [x] AGB, Datenschutz, Impressum (Entwürfe in 7 Sprachen)
|
||||||
@@ -267,9 +279,9 @@ kashilo/
|
|||||||
- Self-hosted Fonts (SIL Open Font License)
|
- Self-hosted Fonts (SIL Open Font License)
|
||||||
|
|
||||||
### Farbpalette
|
### Farbpalette
|
||||||
- **Monochrome Theme** - reine Graustufen
|
- **Warm Teal Theme**
|
||||||
- **Light Mode**: BG #FAFAFA, Text #1A1A1A, Primary #555555
|
- **Light Mode**: BG #FAFAF9, Text #1C1917, Accent #0D9488 (Teal)
|
||||||
- **Dark Mode**: BG #141414, Text #F0F0F0, Primary #AAAAAA
|
- **Dark Mode**: BG #171717, Text #F5F5F4, Accent #2DD4BF (Teal light)
|
||||||
|
|
||||||
### Mobile-First
|
### Mobile-First
|
||||||
- Responsive Grid (2 Spalten Mobile, 5 Spalten Desktop)
|
- Responsive Grid (2 Spalten Mobile, 5 Spalten Desktop)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Haupttabelle für alle Anzeigen.
|
|||||||
| `expires_at` | datetime | Ablaufdatum |
|
| `expires_at` | datetime | Ablaufdatum |
|
||||||
| `monero_address` | string | XMR-Adresse für Zahlung |
|
| `monero_address` | string | XMR-Adresse für Zahlung |
|
||||||
| `contact_public_key` | text | NaCl Public Key für E2E-Chat (pro Listing) |
|
| `contact_public_key` | text | NaCl Public Key für E2E-Chat (pro Listing) |
|
||||||
|
| `verified` | boolean | `false` default — Verifikation abgeschlossen |
|
||||||
|
| `verification_code` | string(6) | 6-stelliger Klartext-Code (für Käufer-Vergleich mit Foto) |
|
||||||
|
| `verification_image` | UUID | FK → directus_files (Verifikationsfoto) |
|
||||||
|
| `verification_date` | datetime | Zeitpunkt der Verifikation |
|
||||||
| `date_created` | datetime | Erstellungsdatum |
|
| `date_created` | datetime | Erstellungsdatum |
|
||||||
| `date_updated` | datetime | Änderungsdatum |
|
| `date_updated` | datetime | Änderungsdatum |
|
||||||
| `user_created` | UUID | Ersteller (FK → directus_users) |
|
| `user_created` | UUID | Ersteller (FK → directus_users) |
|
||||||
@@ -220,7 +224,7 @@ Meldungen von Anzeigen.
|
|||||||
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
||||||
| `reports` | - | ✓ | - | - | Nur erstellen |
|
| `reports` | - | ✓ | - | - | Nur erstellen |
|
||||||
|
|
||||||
### Listings Update-Berechtigungen (Detail)
|
### Listings Update-Berechtigungen — Public Role (Detail)
|
||||||
|
|
||||||
**Custom Filter:**
|
**Custom Filter:**
|
||||||
```json
|
```json
|
||||||
@@ -236,12 +240,28 @@ Meldungen von Anzeigen.
|
|||||||
- `contact_public_key`
|
- `contact_public_key`
|
||||||
- `images`
|
- `images`
|
||||||
- `views` (geschützt durch Flow)
|
- `views` (geschützt durch Flow)
|
||||||
|
- `verified`, `verification_code`, `verification_image`, `verification_date`
|
||||||
|
|
||||||
**Read Filter:**
|
**Read Filter:**
|
||||||
```json
|
```json
|
||||||
{ "status": { "_eq": "published" } }
|
{ "status": { "_eq": "published" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Die Felder `paid_at`, `payment_status` und `btcpay_invoice_id` sind in der Public-Rolle **nicht** lesbar.
|
||||||
|
|
||||||
|
### User Role: Listings (zusätzliche Felder)
|
||||||
|
|
||||||
|
Eingeloggte User haben zusätzlich zu den Public-Feldern Zugriff auf:
|
||||||
|
|
||||||
|
**Read (zusätzlich):**
|
||||||
|
- `paid_at`, `payment_status`, `btcpay_invoice_id`
|
||||||
|
|
||||||
|
**Update:**
|
||||||
|
- Gleiche Felder wie Public Update
|
||||||
|
- Filter: `user_created = $CURRENT_USER`
|
||||||
|
|
||||||
|
**Hinweis:** `paid_at` und `payment_status` dürfen NICHT in der Public-Rolle lesbar sein, da sonst `getListing()` für nicht-eingeloggte Besucher fehlschlägt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directus Flows
|
## Directus Flows
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Differenzierung gegenüber eBay Kleinanzeigen, Tutti, XMRBazaar.
|
Differenzierung gegenüber eBay Kleinanzeigen, Tutti, XMRBazaar.
|
||||||
Drei Features, die kein Konkurrent hat.
|
Drei Features, die kein Konkurrent hat.
|
||||||
|
|
||||||
Status: **Planung** (noch nicht implementiert)
|
Status: **Verifiable Listings** ✅ implementiert, Rest in Planung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { favoritesService } from '../services/favorites.js'
|
|||||||
|
|
||||||
class ListingCard extends HTMLElement {
|
class ListingCard extends HTMLElement {
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority']
|
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority', 'verified']
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -103,8 +103,9 @@ class ListingCard extends HTMLElement {
|
|||||||
const paymentStatus = this.getAttribute('payment-status')
|
const paymentStatus = this.getAttribute('payment-status')
|
||||||
const status = this.getAttribute('status')
|
const status = this.getAttribute('status')
|
||||||
const isDeleted = status === 'deleted'
|
const isDeleted = status === 'deleted'
|
||||||
|
const isPending = status === 'draft' && paymentStatus !== 'paid'
|
||||||
|
|
||||||
const ownerBadge = (this.isOwner && !isDeleted) ? /* html */`
|
const ownerBadge = (this.isOwner && !isDeleted && !isPending) ? /* html */`
|
||||||
<a href="#/edit/${escapeHTML(id)}" class="owner-badge" title="${t('listing.edit')}">
|
<a href="#/edit/${escapeHTML(id)}" class="owner-badge" title="${t('listing.edit')}">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
@@ -141,13 +142,16 @@ class ListingCard extends HTMLElement {
|
|||||||
${paymentBadge}
|
${paymentBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="listing-info">
|
<div class="listing-info">
|
||||||
<h3 class="listing-title">${escapeHTML(title)}</h3>
|
<h3 class="listing-title">${escapeHTML(title)}</h3>
|
||||||
<div class="listing-price-wrapper">
|
<div class="listing-price-wrapper">
|
||||||
<p class="listing-price">${priceDisplay}</p>
|
<p class="listing-price">${priceDisplay}</p>
|
||||||
${secondaryPrice ? `<p class="listing-price-secondary">${secondaryPrice}</p>` : ''}
|
${secondaryPrice ? `<p class="listing-price-secondary">${secondaryPrice}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="listing-meta-row">
|
||||||
|
${this.getAttribute('verified') === 'true' ? `<span class="listing-verified-badge">${t('verification.badge')}</span>` : ''}
|
||||||
<p class="listing-location">${escapeHTML(location)}</p>
|
<p class="listing-location">${escapeHTML(location)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</${linkTag}>
|
</${linkTag}>
|
||||||
${!isDeleted ? /* html */`
|
${!isDeleted ? /* html */`
|
||||||
<button
|
<button
|
||||||
@@ -331,6 +335,19 @@ style.textContent = /* css */`
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listing-card .listing-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
listing-card .listing-verified-badge {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-success);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
listing-card .listing-location {
|
listing-card .listing-location {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { escapeHTML } from '../../utils/helpers.js'
|
|||||||
import '../location-picker.js'
|
import '../location-picker.js'
|
||||||
import '../pow-captcha.js'
|
import '../pow-captcha.js'
|
||||||
import '../image-cropper.js'
|
import '../image-cropper.js'
|
||||||
|
import '../verification-widget.js'
|
||||||
|
|
||||||
const STORAGE_KEY = 'kashilo_create_draft'
|
const STORAGE_KEY = 'kashilo_create_draft'
|
||||||
|
|
||||||
@@ -98,7 +99,10 @@ class PageCreate extends HTMLElement {
|
|||||||
|
|
||||||
async loadExistingListing() {
|
async loadExistingListing() {
|
||||||
try {
|
try {
|
||||||
const listing = await directus.getListing(this.editId)
|
const response = await directus.get(`/items/listings/${this.editId}`, {
|
||||||
|
fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.*']
|
||||||
|
})
|
||||||
|
const listing = response.data
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const user = await auth.getUser()
|
const user = await auth.getUser()
|
||||||
@@ -360,6 +364,16 @@ class PageCreate extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.editMode ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<verification-widget
|
||||||
|
listing-id="${this.editId}"
|
||||||
|
${this.editListing?.verified ? `verified="true"` : ''}
|
||||||
|
${this.editListing?.verification_date ? `verified-date="${this.editListing.verification_date}"` : ''}
|
||||||
|
></verification-widget>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
|
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -432,6 +446,22 @@ class PageCreate extends HTMLElement {
|
|||||||
this.formData.location = e.detail.name
|
this.formData.location = e.detail.name
|
||||||
this.saveDraft()
|
this.saveDraft()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pre-fill location picker in edit mode
|
||||||
|
if (this.editMode && this.editListing?.location && locationPicker) {
|
||||||
|
const loc = this.editListing.location
|
||||||
|
locationPicker.value = {
|
||||||
|
name: loc.name || '',
|
||||||
|
postalCode: loc.postal_code || '',
|
||||||
|
countryCode: loc.country || '',
|
||||||
|
displayName: loc.name || ''
|
||||||
|
}
|
||||||
|
this.formData.locationData = {
|
||||||
|
name: loc.name || '',
|
||||||
|
postalCode: loc.postal_code || '',
|
||||||
|
countryCode: loc.country || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
|
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class PageFavorites extends HTMLElement {
|
|||||||
location="${escapeHTML(locationName)}"
|
location="${escapeHTML(locationName)}"
|
||||||
image="${imageUrl}"
|
image="${imageUrl}"
|
||||||
owner-id="${listing.user_created || ''}"
|
owner-id="${listing.user_created || ''}"
|
||||||
|
${listing.verified ? 'verified="true"' : ''}
|
||||||
></listing-card>
|
></listing-card>
|
||||||
`
|
`
|
||||||
}).join('')
|
}).join('')
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ class PageHome extends HTMLElement {
|
|||||||
location="${escapeHTML(locationName)}"
|
location="${escapeHTML(locationName)}"
|
||||||
image="${imageUrl}"
|
image="${imageUrl}"
|
||||||
owner-id="${listing.user_created || ''}"
|
owner-id="${listing.user_created || ''}"
|
||||||
|
${listing.verified ? 'verified="true"' : ''}
|
||||||
${index < 4 ? 'priority' : ''}
|
${index < 4 ? 'priority' : ''}
|
||||||
></listing-card>
|
></listing-card>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class PageListing extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadSellerReputation() {
|
async loadSellerReputation() {
|
||||||
if (!this.listing?.id) return
|
if (!this.listing?.id || !auth.isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
const convsResponse = await directus.get('/items/conversations', {
|
const convsResponse = await directus.get('/items/conversations', {
|
||||||
filter: { listing_id: { _eq: this.listing.id } },
|
filter: { listing_id: { _eq: this.listing.id } },
|
||||||
@@ -290,6 +290,7 @@ class PageListing extends HTMLElement {
|
|||||||
${priceInfo.secondary ? `<p class="listing-price-secondary">${priceInfo.secondary}</p>` : ''}
|
${priceInfo.secondary ? `<p class="listing-price-secondary">${priceInfo.secondary}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="listing-meta">
|
<div class="listing-meta">
|
||||||
|
${this.listing.verified ? `<span class="meta-item verified-badge">${t('verification.badge')}</span>` : ''}
|
||||||
${this.listing.condition ? `<span class="meta-item">${this.getConditionLabel(this.listing.condition)}</span>` : ''}
|
${this.listing.condition ? `<span class="meta-item">${this.getConditionLabel(this.listing.condition)}</span>` : ''}
|
||||||
${this.listing.shipping ? `<span class="meta-item">📦 ${t('listing.shippingAvailable')}</span>` : ''}
|
${this.listing.shipping ? `<span class="meta-item">📦 ${t('listing.shippingAvailable')}</span>` : ''}
|
||||||
<span class="meta-item views-item"><span class="views-icon">👁</span> ${this.formatViews(this.listing.views || 0)}</span>
|
<span class="meta-item views-item"><span class="views-icon">👁</span> ${this.formatViews(this.listing.views || 0)}</span>
|
||||||
@@ -308,6 +309,8 @@ class PageListing extends HTMLElement {
|
|||||||
<div class="description-text">${this.formatDescription(this.listing.description)}</div>
|
<div class="description-text">${this.formatDescription(this.listing.description)}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
${this.renderVerificationSection()}
|
||||||
|
|
||||||
<!-- Location Mobile (shown only on mobile) -->
|
<!-- Location Mobile (shown only on mobile) -->
|
||||||
${this.listing.location ? `
|
${this.listing.location ? `
|
||||||
<section class="listing-location-section location-mobile">
|
<section class="listing-location-section location-mobile">
|
||||||
@@ -358,6 +361,7 @@ class PageListing extends HTMLElement {
|
|||||||
// Owner view: show edit button instead of contact
|
// Owner view: show edit button instead of contact
|
||||||
if (this.isOwner) {
|
if (this.isOwner) {
|
||||||
const paymentProcessing = this.listing?.payment_status === 'processing'
|
const paymentProcessing = this.listing?.payment_status === 'processing'
|
||||||
|
const paymentPending = this.listing?.status === 'draft' && this.listing?.payment_status !== 'paid'
|
||||||
|
|
||||||
return /* html */`
|
return /* html */`
|
||||||
${paymentProcessing ? `
|
${paymentProcessing ? `
|
||||||
@@ -373,16 +377,16 @@ class PageListing extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="sidebar-card">
|
<div class="sidebar-card">
|
||||||
<a href="#/edit/${this.listingId}" class="btn btn-primary btn-lg sidebar-btn">
|
${!paymentPending ? `
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<a href="#/edit/${this.listingId}" class="btn btn-primary btn-lg sidebar-btn">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
</svg>
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
${t('listing.edit')}
|
</svg>
|
||||||
</a>
|
${t('listing.edit')}
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="sidebar-actions">
|
<div class="sidebar-actions">
|
||||||
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
|
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -482,6 +486,42 @@ class PageListing extends HTMLElement {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderVerificationSection() {
|
||||||
|
if (!this.listing?.verified || !this.listing?.verification_image) return ''
|
||||||
|
|
||||||
|
const imageId = typeof this.listing.verification_image === 'object'
|
||||||
|
? this.listing.verification_image.id
|
||||||
|
: this.listing.verification_image
|
||||||
|
const imageUrl = directus.getFileUrl(imageId)
|
||||||
|
const code = this.listing.verification_code || ''
|
||||||
|
const date = this.listing.verification_date
|
||||||
|
? new Date(this.listing.verification_date).toLocaleDateString()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return /* html */`
|
||||||
|
<section class="verification-section">
|
||||||
|
<h2>${t('verification.verified')}</h2>
|
||||||
|
<p class="verification-hint">${t('verification.proofHint')}</p>
|
||||||
|
<div class="verification-proof">
|
||||||
|
<div class="verification-proof-image">
|
||||||
|
<img src="${imageUrl}" alt="${t('verification.proofPhoto')}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="verification-proof-info">
|
||||||
|
<div class="verification-proof-code">
|
||||||
|
<span class="verification-proof-label">${t('verification.proofCode')}</span>
|
||||||
|
<span class="verification-proof-value">${escapeHTML(code)}</span>
|
||||||
|
</div>
|
||||||
|
${date ? `
|
||||||
|
<div class="verification-proof-date">
|
||||||
|
${t('verification.verifiedDate', { date })}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
renderListingCard(listing) {
|
renderListingCard(listing) {
|
||||||
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
|
||||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||||
@@ -496,6 +536,7 @@ class PageListing extends HTMLElement {
|
|||||||
location="${escapeHTML(locationName)}"
|
location="${escapeHTML(locationName)}"
|
||||||
image="${imageUrl}"
|
image="${imageUrl}"
|
||||||
owner-id="${listing.user_created || ''}"
|
owner-id="${listing.user_created || ''}"
|
||||||
|
${listing.verified ? 'verified="true"' : ''}
|
||||||
></listing-card>
|
></listing-card>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -870,6 +911,12 @@ style.textContent = /* css */`
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
page-listing .verified-badge {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
page-listing .meta-date {
|
page-listing .meta-date {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -886,11 +933,83 @@ style.textContent = /* css */`
|
|||||||
margin-bottom: var(--space-xl);
|
margin-bottom: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
page-listing .verification-section {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-hint {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-image {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 200px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-value {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-date {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
page-listing .verification-proof {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
page-listing .verification-proof-image {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
page-listing .listing-location-section {
|
page-listing .listing-location-section {
|
||||||
margin-bottom: var(--space-xl);
|
margin-bottom: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
page-listing .listing-description h2,
|
page-listing .listing-description h2,
|
||||||
|
page-listing .verification-section h2,
|
||||||
page-listing .listing-location-section h2 {
|
page-listing .listing-location-section h2 {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|||||||
@@ -242,8 +242,10 @@ class PageMyListings extends HTMLElement {
|
|||||||
const statusBadge = this.getStatusBadge(listing)
|
const statusBadge = this.getStatusBadge(listing)
|
||||||
|
|
||||||
const isPublished = listing.status === 'published'
|
const isPublished = listing.status === 'published'
|
||||||
|
const isPending = listing.status === 'draft' && listing.payment_status !== 'paid'
|
||||||
|
|
||||||
let toggleBtn = ''
|
let toggleBtn = ''
|
||||||
if (listingsService.canTogglePublish(listing)) {
|
if (!isPending && listingsService.canTogglePublish(listing)) {
|
||||||
const label = isPublished ? t('myListings.unpublish') : t('myListings.republish')
|
const label = isPublished ? t('myListings.unpublish') : t('myListings.republish')
|
||||||
toggleBtn = /* html */`
|
toggleBtn = /* html */`
|
||||||
<button class="btn-toggle-status" data-id="${listing.id}" data-status="${isPublished ? 'draft' : 'published'}">
|
<button class="btn-toggle-status" data-id="${listing.id}" data-status="${isPublished ? 'draft' : 'published'}">
|
||||||
@@ -257,7 +259,7 @@ class PageMyListings extends HTMLElement {
|
|||||||
deleteBtn = /* html */`
|
deleteBtn = /* html */`
|
||||||
<p class="deleted-hint">${t('myListings.deletedHint')}</p>
|
<p class="deleted-hint">${t('myListings.deletedHint')}</p>
|
||||||
`
|
`
|
||||||
} else if (listing.status !== 'archived') {
|
} else if (!isPending && listing.status !== 'archived') {
|
||||||
deleteBtn = /* html */`
|
deleteBtn = /* html */`
|
||||||
<button class="btn-delete-listing" data-id="${listing.id}">
|
<button class="btn-delete-listing" data-id="${listing.id}">
|
||||||
${t('myListings.delete')}
|
${t('myListings.delete')}
|
||||||
@@ -278,6 +280,7 @@ class PageMyListings extends HTMLElement {
|
|||||||
owner-id="${listing.user_created || ''}"
|
owner-id="${listing.user_created || ''}"
|
||||||
payment-status="${listing.payment_status || ''}"
|
payment-status="${listing.payment_status || ''}"
|
||||||
status="${listing.status || ''}"
|
status="${listing.status || ''}"
|
||||||
|
${listing.verified ? 'verified="true"' : ''}
|
||||||
></listing-card>
|
></listing-card>
|
||||||
${toggleBtn}
|
${toggleBtn}
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
|
|||||||
356
js/components/verification-widget.js
Normal file
356
js/components/verification-widget.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { t, i18n } from '../i18n.js'
|
||||||
|
import { verificationService } from '../services/verification.js'
|
||||||
|
|
||||||
|
class VerificationWidget extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['listing-id', 'verified', 'verified-date']
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.state = 'idle'
|
||||||
|
this.currentCode = null
|
||||||
|
this.timerInterval = null
|
||||||
|
this.i18nUnsubscribe = null
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
const verified = this.getAttribute('verified')
|
||||||
|
if (verified === 'true') {
|
||||||
|
this.state = 'verified'
|
||||||
|
}
|
||||||
|
this.render()
|
||||||
|
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.clearTimer()
|
||||||
|
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback() {
|
||||||
|
if (this.isConnected) {
|
||||||
|
const verified = this.getAttribute('verified')
|
||||||
|
if (verified === 'true') {
|
||||||
|
this.state = 'verified'
|
||||||
|
}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer() {
|
||||||
|
if (this.timerInterval) {
|
||||||
|
clearInterval(this.timerInterval)
|
||||||
|
this.timerInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startVerification() {
|
||||||
|
this.currentCode = verificationService.generateCode()
|
||||||
|
this.state = 'active'
|
||||||
|
this.render()
|
||||||
|
this.startCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
startCountdown() {
|
||||||
|
this.clearTimer()
|
||||||
|
this.timerInterval = setInterval(() => {
|
||||||
|
if (!this.currentCode) {
|
||||||
|
this.clearTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const remaining = this.getRemainingTime()
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this.clearTimer()
|
||||||
|
this.state = 'expired'
|
||||||
|
this.render()
|
||||||
|
this.setupEventListeners()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timerEl = this.querySelector('.verification-timer')
|
||||||
|
if (timerEl) {
|
||||||
|
timerEl.textContent = this.formatTime(remaining)
|
||||||
|
if (remaining <= 60000) {
|
||||||
|
timerEl.classList.add('verification-timer--warning')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingTime() {
|
||||||
|
if (!this.currentCode) return 0
|
||||||
|
const expiresAt = new Date(this.currentCode.expiresAt).getTime()
|
||||||
|
return Math.max(0, expiresAt - Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(ms) {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpload(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const listingId = this.getAttribute('listing-id')
|
||||||
|
if (!listingId || !this.currentCode) return
|
||||||
|
|
||||||
|
this.state = 'uploading'
|
||||||
|
this.render()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await verificationService.verify(listingId, this.currentCode.code, file)
|
||||||
|
if (success) {
|
||||||
|
this.clearTimer()
|
||||||
|
this.state = 'verified'
|
||||||
|
this.render()
|
||||||
|
this.dispatchEvent(new CustomEvent('verification-complete', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { listingId }
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
this.state = 'active'
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Verification failed:', err)
|
||||||
|
this.state = 'active'
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state === 'verified') {
|
||||||
|
this.innerHTML = this.renderVerified()
|
||||||
|
} else if (this.state === 'active' || this.state === 'uploading') {
|
||||||
|
this.innerHTML = this.renderActive()
|
||||||
|
} else if (this.state === 'expired') {
|
||||||
|
this.innerHTML = this.renderExpired()
|
||||||
|
} else {
|
||||||
|
this.innerHTML = this.renderIdle()
|
||||||
|
}
|
||||||
|
this.setupEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderIdle() {
|
||||||
|
return `
|
||||||
|
<div class="verification-widget">
|
||||||
|
<button class="verification-toggle" type="button">
|
||||||
|
<span class="verification-toggle-label">✓ ${t('verification.verify')}</span>
|
||||||
|
<span class="verification-toggle-subtitle">${t('verification.optional')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActive() {
|
||||||
|
const remaining = this.getRemainingTime()
|
||||||
|
const isUploading = this.state === 'uploading'
|
||||||
|
return `
|
||||||
|
<div class="verification-widget verification-widget--active">
|
||||||
|
<div class="verification-code">${this.currentCode.code}</div>
|
||||||
|
<div class="verification-timer${remaining <= 60000 ? ' verification-timer--warning' : ''}">${this.formatTime(remaining)}</div>
|
||||||
|
<p class="verification-instructions">${t('verification.instructions')}</p>
|
||||||
|
<label class="btn btn-outline verification-upload${isUploading ? ' verification-upload--loading' : ''}">
|
||||||
|
${isUploading
|
||||||
|
? `<span class="verification-spinner"></span>`
|
||||||
|
: t('verification.upload')}
|
||||||
|
<input type="file" accept="image/*" capture="environment" hidden${isUploading ? ' disabled' : ''}>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderExpired() {
|
||||||
|
return `
|
||||||
|
<div class="verification-widget verification-widget--expired">
|
||||||
|
<p class="verification-expired-text">${t('verification.expired')}</p>
|
||||||
|
<button class="btn btn-outline verification-regenerate" type="button">✓ ${t('verification.verify')}</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVerified() {
|
||||||
|
const verifiedDate = this.getAttribute('verified-date')
|
||||||
|
const dateStr = verifiedDate
|
||||||
|
? t('verification.verifiedDate', { date: new Date(verifiedDate).toLocaleDateString() })
|
||||||
|
: ''
|
||||||
|
return `
|
||||||
|
<div class="verification-widget verification-widget--verified">
|
||||||
|
<div class="verification-success">
|
||||||
|
<svg class="verification-success-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span>${t('verification.verified')}</span>
|
||||||
|
</div>
|
||||||
|
${dateStr ? `<span class="verification-date">${dateStr}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
const toggleBtn = this.querySelector('.verification-toggle')
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.addEventListener('click', () => this.startVerification())
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateBtn = this.querySelector('.verification-regenerate')
|
||||||
|
if (regenerateBtn) {
|
||||||
|
regenerateBtn.addEventListener('click', () => this.startVerification())
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = this.querySelector('input[type="file"]')
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', (e) => this.handleUpload(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('verification-widget', VerificationWidget)
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = `
|
||||||
|
.verification-widget {
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-toggle:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-toggle-label {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-toggle-subtitle {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-widget--active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5em;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-timer {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-timer--warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-instructions {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-upload {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-upload--loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: verification-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes verification-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-widget--expired {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-expired-text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-widget--verified {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-success {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-success-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-date {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
|
||||||
|
export { VerificationWidget }
|
||||||
@@ -21,7 +21,8 @@ const DEFAULT_FIELDS = [
|
|||||||
'location.postal_code',
|
'location.postal_code',
|
||||||
'location.country',
|
'location.country',
|
||||||
'location.latitude',
|
'location.latitude',
|
||||||
'location.longitude'
|
'location.longitude',
|
||||||
|
'verified'
|
||||||
]
|
]
|
||||||
|
|
||||||
const DETAIL_FIELDS = [
|
const DETAIL_FIELDS = [
|
||||||
@@ -49,7 +50,13 @@ const DETAIL_FIELDS = [
|
|||||||
'location.name',
|
'location.name',
|
||||||
'location.postal_code',
|
'location.postal_code',
|
||||||
'location.country',
|
'location.country',
|
||||||
'contact_public_key'
|
'monero_address',
|
||||||
|
'contact_public_key',
|
||||||
|
'views',
|
||||||
|
'verified',
|
||||||
|
'verification_code',
|
||||||
|
'verification_date',
|
||||||
|
'verification_image'
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function getListings(options = {}) {
|
export async function getListings(options = {}) {
|
||||||
|
|||||||
49
js/services/verification.js
Normal file
49
js/services/verification.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { directus } from './directus.js'
|
||||||
|
|
||||||
|
const CODE_VALIDITY_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
class VerificationService {
|
||||||
|
|
||||||
|
generateCode() {
|
||||||
|
const array = new Uint32Array(1)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
const code = String(array[0] % 1000000).padStart(6, '0')
|
||||||
|
const generatedAt = new Date()
|
||||||
|
const expiresAt = new Date(generatedAt.getTime() + CODE_VALIDITY_MS)
|
||||||
|
return { code, generatedAt, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
isCodeValid(generatedAt) {
|
||||||
|
return Date.now() - new Date(generatedAt).getTime() < CODE_VALIDITY_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingTime(generatedAt) {
|
||||||
|
const elapsed = Date.now() - new Date(generatedAt).getTime()
|
||||||
|
const remaining = Math.max(0, CODE_VALIDITY_MS - elapsed)
|
||||||
|
return Math.ceil(remaining / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(listingId, code, imageFile) {
|
||||||
|
try {
|
||||||
|
const uploaded = await directus.uploadFile(imageFile)
|
||||||
|
|
||||||
|
await directus.patch('/items/listings/' + listingId, {
|
||||||
|
verification_code: code,
|
||||||
|
verification_image: uploaded.id,
|
||||||
|
verification_date: new Date().toISOString(),
|
||||||
|
verified: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Verification failed:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isVerified(listing) {
|
||||||
|
return listing.verified === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verificationService = new VerificationService()
|
||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Bewertung abgeben",
|
"rate": "Bewertung abgeben",
|
||||||
"rated": "Bewertet",
|
"rated": "Bewertet",
|
||||||
"memberSince": "Mitglied seit {{date}}"
|
"memberSince": "Mitglied seit {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Besitz verifizieren",
|
||||||
|
"optional": "Optional — erhöht das Vertrauen",
|
||||||
|
"code": "Dein Code",
|
||||||
|
"validFor": "Gültig noch",
|
||||||
|
"instructions": "Schreibe diesen Code auf einen Zettel und fotografiere deinen Artikel zusammen mit dem Code.",
|
||||||
|
"upload": "Verifikationsfoto hochladen",
|
||||||
|
"verified": "Besitz verifiziert",
|
||||||
|
"verifiedDate": "Verifiziert am {{date}}",
|
||||||
|
"expired": "Code abgelaufen — neuen generieren",
|
||||||
|
"badge": "✓ Verifiziert",
|
||||||
|
"proofHint": "Der Verkäufer hat diesen Artikel mit einem von kashilo generierten Code fotografiert. Vergleiche den Code im Foto mit dem angezeigten Code.",
|
||||||
|
"proofCode": "Verifizierungscode",
|
||||||
|
"proofPhoto": "Verifikationsfoto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Leave a rating",
|
"rate": "Leave a rating",
|
||||||
"rated": "Rated",
|
"rated": "Rated",
|
||||||
"memberSince": "Member since {{date}}"
|
"memberSince": "Member since {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Verify ownership",
|
||||||
|
"optional": "Optional — increases trust",
|
||||||
|
"code": "Your code",
|
||||||
|
"validFor": "Valid for",
|
||||||
|
"instructions": "Write this code on a piece of paper and photograph your item together with the code.",
|
||||||
|
"upload": "Upload verification photo",
|
||||||
|
"verified": "Ownership verified",
|
||||||
|
"verifiedDate": "Verified on {{date}}",
|
||||||
|
"expired": "Code expired — generate new one",
|
||||||
|
"badge": "✓ Verified",
|
||||||
|
"proofHint": "The seller photographed this item with a code generated by kashilo. Compare the code in the photo with the code shown here.",
|
||||||
|
"proofCode": "Verification code",
|
||||||
|
"proofPhoto": "Verification photo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Dejar una valoración",
|
"rate": "Dejar una valoración",
|
||||||
"rated": "Valorado",
|
"rated": "Valorado",
|
||||||
"memberSince": "Miembro desde {{date}}"
|
"memberSince": "Miembro desde {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Verificar posesión",
|
||||||
|
"optional": "Opcional — aumenta la confianza",
|
||||||
|
"code": "Tu código",
|
||||||
|
"validFor": "Válido aún",
|
||||||
|
"instructions": "Escribe este código en un papel y fotografía tu artículo junto con el código.",
|
||||||
|
"upload": "Subir foto de verificación",
|
||||||
|
"verified": "Posesión verificada",
|
||||||
|
"verifiedDate": "Verificado el {{date}}",
|
||||||
|
"expired": "Código expirado — generar uno nuevo",
|
||||||
|
"badge": "✓ Verificado",
|
||||||
|
"proofHint": "El vendedor fotografió este artículo con un código generado por kashilo. Compara el código en la foto con el código mostrado aquí.",
|
||||||
|
"proofCode": "Código de verificación",
|
||||||
|
"proofPhoto": "Foto de verificación"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Donner une évaluation",
|
"rate": "Donner une évaluation",
|
||||||
"rated": "Évalué",
|
"rated": "Évalué",
|
||||||
"memberSince": "Membre depuis {{date}}"
|
"memberSince": "Membre depuis {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Vérifier la possession",
|
||||||
|
"optional": "Optionnel — augmente la confiance",
|
||||||
|
"code": "Votre code",
|
||||||
|
"validFor": "Valide encore",
|
||||||
|
"instructions": "Écrivez ce code sur un papier et photographiez votre article avec le code.",
|
||||||
|
"upload": "Télécharger la photo de vérification",
|
||||||
|
"verified": "Possession vérifiée",
|
||||||
|
"verifiedDate": "Vérifié le {{date}}",
|
||||||
|
"expired": "Code expiré — générer un nouveau",
|
||||||
|
"badge": "✓ Vérifié",
|
||||||
|
"proofHint": "Le vendeur a photographié cet article avec un code généré par kashilo. Comparez le code sur la photo avec le code affiché ici.",
|
||||||
|
"proofCode": "Code de vérification",
|
||||||
|
"proofPhoto": "Photo de vérification"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Lascia una valutazione",
|
"rate": "Lascia una valutazione",
|
||||||
"rated": "Valutato",
|
"rated": "Valutato",
|
||||||
"memberSince": "Membro dal {{date}}"
|
"memberSince": "Membro dal {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Verificare il possesso",
|
||||||
|
"optional": "Opzionale — aumenta la fiducia",
|
||||||
|
"code": "Il tuo codice",
|
||||||
|
"validFor": "Valido ancora",
|
||||||
|
"instructions": "Scrivi questo codice su un foglio e fotografa il tuo articolo insieme al codice.",
|
||||||
|
"upload": "Carica foto di verifica",
|
||||||
|
"verified": "Possesso verificato",
|
||||||
|
"verifiedDate": "Verificato il {{date}}",
|
||||||
|
"expired": "Codice scaduto — generarne uno nuovo",
|
||||||
|
"badge": "✓ Verificato",
|
||||||
|
"proofHint": "Il venditore ha fotografato questo articolo con un codice generato da kashilo. Confronta il codice nella foto con il codice mostrato qui.",
|
||||||
|
"proofCode": "Codice di verifica",
|
||||||
|
"proofPhoto": "Foto di verifica"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Deixar uma avaliação",
|
"rate": "Deixar uma avaliação",
|
||||||
"rated": "Avaliado",
|
"rated": "Avaliado",
|
||||||
"memberSince": "Membro desde {{date}}"
|
"memberSince": "Membro desde {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Verificar posse",
|
||||||
|
"optional": "Opcional — aumenta a confiança",
|
||||||
|
"code": "Seu código",
|
||||||
|
"validFor": "Válido ainda",
|
||||||
|
"instructions": "Escreva este código em um papel e fotografe seu artigo junto com o código.",
|
||||||
|
"upload": "Enviar foto de verificação",
|
||||||
|
"verified": "Posse verificada",
|
||||||
|
"verifiedDate": "Verificado em {{date}}",
|
||||||
|
"expired": "Código expirado — gerar novo",
|
||||||
|
"badge": "✓ Verificado",
|
||||||
|
"proofHint": "O vendedor fotografou este artigo com um código gerado pelo kashilo. Compare o código na foto com o código exibido aqui.",
|
||||||
|
"proofCode": "Código de verificação",
|
||||||
|
"proofPhoto": "Foto de verificação"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,5 +345,20 @@
|
|||||||
"rate": "Оставить оценку",
|
"rate": "Оставить оценку",
|
||||||
"rated": "Оценено",
|
"rated": "Оценено",
|
||||||
"memberSince": "Участник с {{date}}"
|
"memberSince": "Участник с {{date}}"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"verify": "Подтвердить владение",
|
||||||
|
"optional": "Необязательно — повышает доверие",
|
||||||
|
"code": "Ваш код",
|
||||||
|
"validFor": "Действителен ещё",
|
||||||
|
"instructions": "Напишите этот код на листке бумаги и сфотографируйте ваш товар вместе с кодом.",
|
||||||
|
"upload": "Загрузить фото подтверждения",
|
||||||
|
"verified": "Владение подтверждено",
|
||||||
|
"verifiedDate": "Подтверждено {{date}}",
|
||||||
|
"expired": "Код истёк — сгенерировать новый",
|
||||||
|
"badge": "✓ Подтверждено",
|
||||||
|
"proofHint": "Продавец сфотографировал этот товар с кодом, сгенерированным kashilo. Сравните код на фото с кодом, показанным здесь.",
|
||||||
|
"proofCode": "Код подтверждения",
|
||||||
|
"proofPhoto": "Фото подтверждения"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user