add new services for listings, conversations, locations and categories
This commit is contained in:
@@ -4,10 +4,12 @@ import { auth } from '../../services/auth.js'
|
|||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
import { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dgray_create_draft'
|
||||||
|
|
||||||
class PageCreate extends HTMLElement {
|
class PageCreate extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.formData = {
|
this.formData = this.loadDraft() || {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
price: '',
|
price: '',
|
||||||
@@ -26,6 +28,27 @@ class PageCreate extends HTMLElement {
|
|||||||
this.submitting = false
|
this.submitting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadDraft() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return saved ? JSON.parse(saved) : null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDraft() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.formData))
|
||||||
|
} catch (e) {
|
||||||
|
// Storage full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDraft() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
// Check if logged in
|
// Check if logged in
|
||||||
if (!auth.isLoggedIn()) {
|
if (!auth.isLoggedIn()) {
|
||||||
@@ -33,6 +56,7 @@ class PageCreate extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
|
||||||
await this.loadCategories()
|
await this.loadCategories()
|
||||||
this.render()
|
this.render()
|
||||||
this.unsubscribe = i18n.subscribe(() => this.render())
|
this.unsubscribe = i18n.subscribe(() => this.render())
|
||||||
@@ -67,6 +91,17 @@ class PageCreate extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateMoneroAddress(address) {
|
||||||
|
if (!address) return false
|
||||||
|
// Standard addresses: start with 4, 95 chars
|
||||||
|
// Subaddresses: start with 8, 95 chars
|
||||||
|
// Integrated addresses: start with 4, 106 chars
|
||||||
|
const standardRegex = /^4[0-9A-Za-z]{94}$/
|
||||||
|
const subaddressRegex = /^8[0-9A-Za-z]{94}$/
|
||||||
|
const integratedRegex = /^4[0-9A-Za-z]{105}$/
|
||||||
|
return standardRegex.test(address) || subaddressRegex.test(address) || integratedRegex.test(address)
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.unsubscribe) this.unsubscribe()
|
if (this.unsubscribe) this.unsubscribe()
|
||||||
}
|
}
|
||||||
@@ -76,6 +111,13 @@ class PageCreate extends HTMLElement {
|
|||||||
<div class="create-page">
|
<div class="create-page">
|
||||||
<h1 data-i18n="create.title">${t('create.title')}</h1>
|
<h1 data-i18n="create.title">${t('create.title')}</h1>
|
||||||
|
|
||||||
|
${this.hasDraft ? `
|
||||||
|
<div class="draft-notice">
|
||||||
|
<span>${t('create.draftRestored')}</span>
|
||||||
|
<button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<form id="create-form" class="create-form">
|
<form id="create-form" class="create-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
|
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
|
||||||
@@ -97,7 +139,7 @@ class PageCreate extends HTMLElement {
|
|||||||
<option value="">${t('create.selectCategory')}</option>
|
<option value="">${t('create.selectCategory')}</option>
|
||||||
${this.categories.map(cat => `
|
${this.categories.map(cat => `
|
||||||
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
|
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
|
||||||
${cat.name}
|
${t(`categories.${cat.slug}`) || cat.name}
|
||||||
</option>
|
</option>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</select>
|
</select>
|
||||||
@@ -237,6 +279,7 @@ class PageCreate extends HTMLElement {
|
|||||||
input.addEventListener('input', (e) => {
|
input.addEventListener('input', (e) => {
|
||||||
if (e.target.name) {
|
if (e.target.name) {
|
||||||
this.formData[e.target.name] = e.target.value
|
this.formData[e.target.name] = e.target.value
|
||||||
|
this.saveDraft()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -245,9 +288,31 @@ class PageCreate extends HTMLElement {
|
|||||||
const shippingCheckbox = this.querySelector('#shipping')
|
const shippingCheckbox = this.querySelector('#shipping')
|
||||||
shippingCheckbox?.addEventListener('change', (e) => {
|
shippingCheckbox?.addEventListener('change', (e) => {
|
||||||
this.formData.shipping = e.target.checked
|
this.formData.shipping = e.target.checked
|
||||||
|
this.saveDraft()
|
||||||
})
|
})
|
||||||
|
|
||||||
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
|
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
|
||||||
|
|
||||||
|
// Clear draft button
|
||||||
|
this.querySelector('#clear-draft-btn')?.addEventListener('click', () => {
|
||||||
|
this.clearDraft()
|
||||||
|
this.formData = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
price_mode: 'fiat',
|
||||||
|
price_type: 'fixed',
|
||||||
|
category: '',
|
||||||
|
condition: 'good',
|
||||||
|
location: '',
|
||||||
|
shipping: false,
|
||||||
|
moneroAddress: ''
|
||||||
|
}
|
||||||
|
this.hasDraft = false
|
||||||
|
this.render()
|
||||||
|
this.setupEventListeners()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageSelect(e) {
|
handleImageSelect(e) {
|
||||||
@@ -311,7 +376,15 @@ class PageCreate extends HTMLElement {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (this.submitting) return
|
if (this.submitting) return
|
||||||
|
|
||||||
|
// Validate Monero address
|
||||||
|
if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) {
|
||||||
|
this.showError(t('create.invalidMoneroAddress'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
|
this.clearError()
|
||||||
|
|
||||||
const form = e.target
|
const form = e.target
|
||||||
const submitBtn = form.querySelector('[type="submit"]')
|
const submitBtn = form.querySelector('[type="submit"]')
|
||||||
@@ -329,43 +402,80 @@ class PageCreate extends HTMLElement {
|
|||||||
// Create listing
|
// Create listing
|
||||||
const listingData = {
|
const listingData = {
|
||||||
title: this.formData.title,
|
title: this.formData.title,
|
||||||
|
slug: this.generateSlug(this.formData.title),
|
||||||
description: this.formData.description,
|
description: this.formData.description,
|
||||||
price: parseFloat(this.formData.price) || 0,
|
price: String(parseFloat(this.formData.price) || 0),
|
||||||
currency: this.formData.currency,
|
currency: this.formData.currency,
|
||||||
price_mode: this.formData.price_mode,
|
|
||||||
price_type: this.formData.price_type,
|
|
||||||
category: this.formData.category || null,
|
|
||||||
condition: this.formData.condition,
|
|
||||||
shipping: this.formData.shipping,
|
|
||||||
monero_address: this.formData.moneroAddress,
|
|
||||||
status: 'published'
|
status: 'published'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add optional fields only if set
|
||||||
|
if (this.formData.price_mode) listingData.price_mode = this.formData.price_mode
|
||||||
|
if (this.formData.category) listingData.category = this.formData.category
|
||||||
|
if (this.formData.condition) listingData.condition = this.formData.condition
|
||||||
|
if (this.formData.location) listingData.location = this.formData.location
|
||||||
|
if (this.formData.shipping) listingData.shipping = this.formData.shipping
|
||||||
|
if (this.formData.moneroAddress) listingData.monero_address = this.formData.moneroAddress
|
||||||
|
|
||||||
// Add images if uploaded
|
// Add images if uploaded
|
||||||
if (imageIds.length > 0) {
|
if (imageIds.length > 0) {
|
||||||
listingData.images = imageIds.map((id, index) => ({
|
listingData.images = imageIds
|
||||||
directus_files_id: id,
|
|
||||||
sort: index
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Creating listing:', listingData)
|
||||||
const listing = await directus.createListing(listingData)
|
const listing = await directus.createListing(listingData)
|
||||||
|
console.log('Created listing:', listing)
|
||||||
|
|
||||||
router.navigate(`/listing/${listing.id}`)
|
this.clearDraft()
|
||||||
|
|
||||||
|
if (listing?.id) {
|
||||||
|
router.navigate(`/listing/${listing.id}`)
|
||||||
|
} else {
|
||||||
|
// Listing created but no ID returned - go to home
|
||||||
|
router.navigate('/')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create listing:', error)
|
console.error('Failed to create listing:', error)
|
||||||
|
console.error('Error details:', JSON.stringify(error.data, null, 2))
|
||||||
submitBtn.disabled = false
|
submitBtn.disabled = false
|
||||||
submitBtn.textContent = t('create.publish')
|
submitBtn.textContent = t('create.publish')
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
alert(error.message || 'Failed to create listing')
|
|
||||||
|
// Extract detailed error message
|
||||||
|
const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed')
|
||||||
|
this.showError(errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
let errorDiv = this.querySelector('.form-error')
|
||||||
|
if (!errorDiv) {
|
||||||
|
errorDiv = document.createElement('div')
|
||||||
|
errorDiv.className = 'form-error'
|
||||||
|
this.querySelector('.form-actions')?.insertAdjacentElement('beforebegin', errorDiv)
|
||||||
|
}
|
||||||
|
errorDiv.textContent = message
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
this.querySelector('.form-error')?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement('div')
|
const div = document.createElement('div')
|
||||||
div.textContent = text
|
div.textContent = text
|
||||||
return div.innerHTML
|
return div.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateSlug(title) {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('page-create', PageCreate)
|
customElements.define('page-create', PageCreate)
|
||||||
@@ -508,5 +618,42 @@ style.textContent = /* css */`
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--space-xl);
|
margin-top: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
page-create .form-error {
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-create .draft-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
page-create .btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
page-create .btn-link:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
`
|
`
|
||||||
document.head.appendChild(style)
|
document.head.appendChild(style)
|
||||||
|
|||||||
153
js/services/categories.js
Normal file
153
js/services/categories.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Categories Service - Handles category tree and translations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { directus } from './directus.js'
|
||||||
|
import { getCurrentLanguage } from '../i18n.js'
|
||||||
|
|
||||||
|
class CategoriesService {
|
||||||
|
constructor() {
|
||||||
|
this.cache = null
|
||||||
|
this.cacheTimestamp = 0
|
||||||
|
this.cacheTimeout = 10 * 60 * 1000 // 10 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
if (this.cache && Date.now() - this.cacheTimestamp < this.cacheTimeout) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await directus.getCategories()
|
||||||
|
this.cache = categories
|
||||||
|
this.cacheTimestamp = Date.now()
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
return directus.getCategory(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBySlug(slug) {
|
||||||
|
return directus.getCategory(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTree() {
|
||||||
|
return directus.getCategoryTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubcategories(parentId) {
|
||||||
|
return directus.getSubcategories(parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRootCategories() {
|
||||||
|
const all = await this.getAll()
|
||||||
|
return all.filter(cat => !cat.parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategoryPath(categoryId) {
|
||||||
|
const all = await this.getAll()
|
||||||
|
const path = []
|
||||||
|
let current = all.find(c => c.id === categoryId)
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current)
|
||||||
|
const parentId = current.parent?.id || current.parent
|
||||||
|
current = parentId ? all.find(c => c.id === parentId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategoryWithChildren(categoryId) {
|
||||||
|
const all = await this.getAll()
|
||||||
|
const category = all.find(c => c.id === categoryId)
|
||||||
|
if (!category) return null
|
||||||
|
|
||||||
|
const collectChildren = (parentId) => {
|
||||||
|
return all
|
||||||
|
.filter(c => (c.parent?.id || c.parent) === parentId)
|
||||||
|
.map(c => ({
|
||||||
|
...c,
|
||||||
|
children: collectChildren(c.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
children: collectChildren(categoryId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTranslatedName(category, lang = null) {
|
||||||
|
const currentLang = lang || getCurrentLanguage()
|
||||||
|
|
||||||
|
if (category.translations && Array.isArray(category.translations)) {
|
||||||
|
const translation = category.translations.find(
|
||||||
|
t => t.languages_code === currentLang || t.languages_code?.startsWith(currentLang)
|
||||||
|
)
|
||||||
|
if (translation?.name) {
|
||||||
|
return translation.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return category.name
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCategoryPath(categories, lang = null) {
|
||||||
|
return categories
|
||||||
|
.map(cat => this.getTranslatedName(cat, lang))
|
||||||
|
.join(' › ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchCategories(query) {
|
||||||
|
if (!query || query.length < 2) return []
|
||||||
|
|
||||||
|
const all = await this.getAll()
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
return all.filter(cat => {
|
||||||
|
if (cat.name?.toLowerCase().includes(lowerQuery)) return true
|
||||||
|
if (cat.slug?.toLowerCase().includes(lowerQuery)) return true
|
||||||
|
|
||||||
|
if (cat.translations) {
|
||||||
|
return cat.translations.some(t =>
|
||||||
|
t.name?.toLowerCase().includes(lowerQuery)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategoriesForSelect(includeChildren = true) {
|
||||||
|
const tree = await this.getTree()
|
||||||
|
const options = []
|
||||||
|
|
||||||
|
const flatten = (categories, depth = 0) => {
|
||||||
|
for (const cat of categories) {
|
||||||
|
options.push({
|
||||||
|
id: cat.id,
|
||||||
|
name: this.getTranslatedName(cat),
|
||||||
|
slug: cat.slug,
|
||||||
|
icon: cat.icon,
|
||||||
|
depth,
|
||||||
|
label: ' '.repeat(depth) + this.getTranslatedName(cat)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (includeChildren && cat.children?.length > 0) {
|
||||||
|
flatten(cat.children, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten(tree)
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
this.cache = null
|
||||||
|
this.cacheTimestamp = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoriesService = new CategoriesService()
|
||||||
206
js/services/conversations.js
Normal file
206
js/services/conversations.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Conversations Service - Zero-Knowledge Chat Implementation
|
||||||
|
* Handles encrypted messaging between users using participant hashes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { directus } from './directus.js'
|
||||||
|
import { cryptoService } from './crypto.js'
|
||||||
|
import { authService } from './auth.js'
|
||||||
|
|
||||||
|
class ConversationsService {
|
||||||
|
constructor() {
|
||||||
|
this.pollingInterval = null
|
||||||
|
this.subscribers = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParticipantHash() {
|
||||||
|
await cryptoService.ready
|
||||||
|
const publicKey = cryptoService.getPublicKey()
|
||||||
|
return this.hashPublicKey(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashPublicKey(publicKey) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(publicKey)
|
||||||
|
return crypto.subtle.digest('SHA-256', data).then(hash => {
|
||||||
|
return Array.from(new Uint8Array(hash))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyConversations() {
|
||||||
|
const participantHash = await this.getParticipantHash()
|
||||||
|
const conversations = await directus.getConversations(participantHash)
|
||||||
|
|
||||||
|
return conversations.map(conv => ({
|
||||||
|
...conv,
|
||||||
|
isParticipant1: conv.participant_hash_1 === participantHash,
|
||||||
|
otherParticipantHash: conv.participant_hash_1 === participantHash
|
||||||
|
? conv.participant_hash_2
|
||||||
|
: conv.participant_hash_1,
|
||||||
|
otherPublicKey: conv.participant_hash_1 === participantHash
|
||||||
|
? conv.public_key_2
|
||||||
|
: conv.public_key_1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversation(id) {
|
||||||
|
const conversation = await directus.getConversation(id)
|
||||||
|
if (!conversation) return null
|
||||||
|
|
||||||
|
const participantHash = await this.getParticipantHash()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...conversation,
|
||||||
|
isParticipant1: conversation.participant_hash_1 === participantHash,
|
||||||
|
otherParticipantHash: conversation.participant_hash_1 === participantHash
|
||||||
|
? conversation.participant_hash_2
|
||||||
|
: conversation.participant_hash_1,
|
||||||
|
otherPublicKey: conversation.participant_hash_1 === participantHash
|
||||||
|
? conversation.public_key_2
|
||||||
|
: conversation.public_key_1,
|
||||||
|
myPublicKey: conversation.participant_hash_1 === participantHash
|
||||||
|
? conversation.public_key_1
|
||||||
|
: conversation.public_key_2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(conversationId, otherPublicKey) {
|
||||||
|
await cryptoService.ready
|
||||||
|
|
||||||
|
const messages = await directus.getConversationMessages(conversationId)
|
||||||
|
const myHash = await this.getParticipantHash()
|
||||||
|
|
||||||
|
return messages.map(msg => {
|
||||||
|
const isOwn = msg.sender_hash === myHash
|
||||||
|
let text = '[Encrypted]'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isOwn) {
|
||||||
|
text = cryptoService.decryptOwn(msg.content_encrypted, msg.nonce)
|
||||||
|
} else {
|
||||||
|
text = cryptoService.decrypt(msg.content_encrypted, msg.nonce, otherPublicKey)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
text = '[Decryption failed]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
text,
|
||||||
|
isOwn,
|
||||||
|
type: msg.type,
|
||||||
|
timestamp: msg.date_created
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(conversationId, otherPublicKey, plainText, type = 'text') {
|
||||||
|
await cryptoService.ready
|
||||||
|
|
||||||
|
const { nonce, ciphertext } = cryptoService.encrypt(plainText, otherPublicKey)
|
||||||
|
const senderHash = await this.getParticipantHash()
|
||||||
|
|
||||||
|
const message = await directus.sendMessage(
|
||||||
|
conversationId,
|
||||||
|
senderHash,
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
type
|
||||||
|
)
|
||||||
|
|
||||||
|
this.notifySubscribers()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
text: plainText,
|
||||||
|
isOwn: true,
|
||||||
|
type: message.type,
|
||||||
|
timestamp: message.date_created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startOrGetConversation(listingId, sellerPublicKey) {
|
||||||
|
await cryptoService.ready
|
||||||
|
|
||||||
|
const myPublicKey = cryptoService.getPublicKey()
|
||||||
|
const myHash = await this.getParticipantHash()
|
||||||
|
const sellerHash = await this.hashPublicKey(sellerPublicKey)
|
||||||
|
|
||||||
|
const existing = await directus.findConversation(listingId, myHash, sellerHash)
|
||||||
|
if (existing) {
|
||||||
|
return this.getConversation(existing.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConv = await directus.startConversation(
|
||||||
|
listingId,
|
||||||
|
myHash,
|
||||||
|
sellerHash,
|
||||||
|
myPublicKey,
|
||||||
|
sellerPublicKey
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.getConversation(newConv.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeConversation(id) {
|
||||||
|
return directus.updateConversationStatus(id, 'closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling(intervalMs = 10000) {
|
||||||
|
if (this.pollingInterval) return
|
||||||
|
|
||||||
|
this.pollingInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this.getMyConversations()
|
||||||
|
this.notifySubscribers()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Conversation polling failed:', e)
|
||||||
|
}
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.pollingInterval) {
|
||||||
|
clearInterval(this.pollingInterval)
|
||||||
|
this.pollingInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(callback) {
|
||||||
|
this.subscribers.add(callback)
|
||||||
|
return () => this.subscribers.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifySubscribers() {
|
||||||
|
this.subscribers.forEach(cb => cb())
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMessageTime(timestamp, locale = 'de-DE') {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
if (diff < 60000) {
|
||||||
|
return 'Gerade eben'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
return `vor ${mins} Min.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||||
|
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 604800000) {
|
||||||
|
return date.toLocaleDateString(locale, { weekday: 'short', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversationsService = new ConversationsService()
|
||||||
@@ -254,8 +254,12 @@ class DirectusService {
|
|||||||
'images.directus_files_id.title',
|
'images.directus_files_id.title',
|
||||||
'category.id',
|
'category.id',
|
||||||
'category.name',
|
'category.name',
|
||||||
'user_created.id',
|
'category.slug',
|
||||||
'user_created.first_name'
|
'category.icon',
|
||||||
|
'location.id',
|
||||||
|
'location.name',
|
||||||
|
'location.region',
|
||||||
|
'user_created.id'
|
||||||
],
|
],
|
||||||
filter: options.filter || { status: { _eq: 'published' } },
|
filter: options.filter || { status: { _eq: 'published' } },
|
||||||
sort: options.sort || ['-date_created'],
|
sort: options.sort || ['-date_created'],
|
||||||
@@ -280,9 +284,9 @@ class DirectusService {
|
|||||||
'*',
|
'*',
|
||||||
'images.directus_files_id.*',
|
'images.directus_files_id.*',
|
||||||
'category.*',
|
'category.*',
|
||||||
|
'category.translations.*',
|
||||||
|
'location.*',
|
||||||
'user_created.id',
|
'user_created.id',
|
||||||
'user_created.first_name',
|
|
||||||
'user_created.avatar',
|
|
||||||
'user_created.date_created'
|
'user_created.date_created'
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -291,7 +295,7 @@ class DirectusService {
|
|||||||
|
|
||||||
async createListing(data) {
|
async createListing(data) {
|
||||||
const response = await this.post('/items/listings', data)
|
const response = await this.post('/items/listings', data)
|
||||||
return response.data
|
return response?.data || response
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateListing(id, data) {
|
async updateListing(id, data) {
|
||||||
@@ -305,7 +309,7 @@ class DirectusService {
|
|||||||
|
|
||||||
async getMyListings() {
|
async getMyListings() {
|
||||||
const response = await this.get('/items/listings', {
|
const response = await this.get('/items/listings', {
|
||||||
fields: ['*', 'images.directus_files_id.id'],
|
fields: ['*', 'images.directus_files_id.id', 'category.name', 'location.name'],
|
||||||
filter: { user_created: { _eq: '$CURRENT_USER' } },
|
filter: { user_created: { _eq: '$CURRENT_USER' } },
|
||||||
sort: ['-date_created']
|
sort: ['-date_created']
|
||||||
})
|
})
|
||||||
@@ -319,17 +323,65 @@ class DirectusService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getListingsByCategory(categoryId, options = {}) {
|
||||||
|
return this.getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
category: { _eq: categoryId }
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListingsByLocation(locationId, options = {}) {
|
||||||
|
return this.getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
location: { _eq: locationId }
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementViews(id) {
|
||||||
|
const listing = await this.getListing(id)
|
||||||
|
if (listing) {
|
||||||
|
return this.patch(`/items/listings/${id}`, {
|
||||||
|
views: (listing.views || 0) + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Categories (Kategorien) ====================
|
// ==================== Categories (Kategorien) ====================
|
||||||
|
|
||||||
async getCategories() {
|
async getCategories() {
|
||||||
const response = await this.get('/items/categories', {
|
const response = await this.get('/items/categories', {
|
||||||
fields: ['id', 'name', 'slug', 'icon', 'parent.id', 'parent.name'],
|
fields: ['*', 'translations.*', 'parent.id', 'parent.name'],
|
||||||
|
filter: { status: { _eq: 'published' } },
|
||||||
sort: ['sort', 'name'],
|
sort: ['sort', 'name'],
|
||||||
limit: -1
|
limit: -1
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategory(idOrSlug) {
|
||||||
|
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug)
|
||||||
|
|
||||||
|
if (isUuid) {
|
||||||
|
const response = await this.get(`/items/categories/${idOrSlug}`, {
|
||||||
|
fields: ['*', 'translations.*', 'parent.*']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.get('/items/categories', {
|
||||||
|
fields: ['*', 'translations.*', 'parent.*'],
|
||||||
|
filter: { slug: { _eq: idOrSlug } },
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
return response.data?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
async getCategoryTree() {
|
async getCategoryTree() {
|
||||||
const categories = await this.getCategories()
|
const categories = await this.getCategories()
|
||||||
return this.buildCategoryTree(categories)
|
return this.buildCategoryTree(categories)
|
||||||
@@ -344,25 +396,73 @@ class DirectusService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Messages (Nachrichten) ====================
|
async getSubcategories(parentId) {
|
||||||
|
const response = await this.get('/items/categories', {
|
||||||
|
fields: ['*', 'translations.*'],
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
parent: { _eq: parentId }
|
||||||
|
},
|
||||||
|
sort: ['sort', 'name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
async getConversations() {
|
// ==================== Locations (Standorte) ====================
|
||||||
|
|
||||||
|
async getLocations(options = {}) {
|
||||||
|
const response = await this.get('/items/locations', {
|
||||||
|
fields: options.fields || ['*'],
|
||||||
|
filter: options.filter || {},
|
||||||
|
sort: options.sort || ['name'],
|
||||||
|
limit: options.limit || -1
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocation(id) {
|
||||||
|
const response = await this.get(`/items/locations/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchLocations(query) {
|
||||||
|
const response = await this.get('/items/locations', {
|
||||||
|
search: query,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationsByRegion(region) {
|
||||||
|
const response = await this.get('/items/locations', {
|
||||||
|
filter: { region: { _eq: region } },
|
||||||
|
sort: ['name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocationsByCountry(country) {
|
||||||
|
const response = await this.get('/items/locations', {
|
||||||
|
filter: { country: { _eq: country } },
|
||||||
|
sort: ['region', 'name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Conversations (Zero-Knowledge Chat) ====================
|
||||||
|
|
||||||
|
async getConversations(participantHash) {
|
||||||
const response = await this.get('/items/conversations', {
|
const response = await this.get('/items/conversations', {
|
||||||
fields: [
|
fields: [
|
||||||
'*',
|
'*',
|
||||||
'listing.id',
|
'listing_id.id',
|
||||||
'listing.title',
|
'listing_id.title',
|
||||||
'listing.images.directus_files_id.id',
|
'listing_id.images.directus_files_id.id'
|
||||||
'buyer.id',
|
|
||||||
'buyer.first_name',
|
|
||||||
'seller.id',
|
|
||||||
'seller.first_name',
|
|
||||||
'messages.*'
|
|
||||||
],
|
],
|
||||||
filter: {
|
filter: {
|
||||||
_or: [
|
_or: [
|
||||||
{ buyer: { _eq: '$CURRENT_USER' } },
|
{ participant_hash_1: { _eq: participantHash } },
|
||||||
{ seller: { _eq: '$CURRENT_USER' } }
|
{ participant_hash_2: { _eq: participantHash } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
sort: ['-date_updated']
|
sort: ['-date_updated']
|
||||||
@@ -374,35 +474,74 @@ class DirectusService {
|
|||||||
const response = await this.get(`/items/conversations/${id}`, {
|
const response = await this.get(`/items/conversations/${id}`, {
|
||||||
fields: [
|
fields: [
|
||||||
'*',
|
'*',
|
||||||
'listing.*',
|
'listing_id.*',
|
||||||
'listing.images.directus_files_id.*',
|
'listing_id.images.directus_files_id.*'
|
||||||
'buyer.*',
|
|
||||||
'seller.*',
|
|
||||||
'messages.*',
|
|
||||||
'messages.sender.*'
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(conversationId, content) {
|
async getConversationMessages(conversationId) {
|
||||||
const response = await this.post('/items/messages', {
|
const response = await this.get('/items/messages', {
|
||||||
conversation: conversationId,
|
fields: ['*'],
|
||||||
content
|
filter: { conversation: { _eq: conversationId } },
|
||||||
|
sort: ['date_created']
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async startConversation(listingId, message) {
|
async sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') {
|
||||||
const response = await this.post('/items/conversations', {
|
const response = await this.post('/items/messages', {
|
||||||
listing: listingId,
|
conversation: conversationId,
|
||||||
messages: {
|
sender_hash: senderHash,
|
||||||
create: [{ content: message }]
|
content_encrypted: encryptedContent,
|
||||||
}
|
nonce: nonce,
|
||||||
|
type: type
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) {
|
||||||
|
const response = await this.post('/items/conversations', {
|
||||||
|
listing_id: listingId,
|
||||||
|
participant_hash_1: participantHash1,
|
||||||
|
participant_hash_2: participantHash2,
|
||||||
|
public_key_1: publicKey1,
|
||||||
|
public_key_2: publicKey2,
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConversation(listingId, participantHash1, participantHash2) {
|
||||||
|
const response = await this.get('/items/conversations', {
|
||||||
|
filter: {
|
||||||
|
listing_id: { _eq: listingId },
|
||||||
|
_or: [
|
||||||
|
{
|
||||||
|
_and: [
|
||||||
|
{ participant_hash_1: { _eq: participantHash1 } },
|
||||||
|
{ participant_hash_2: { _eq: participantHash2 } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_and: [
|
||||||
|
{ participant_hash_1: { _eq: participantHash2 } },
|
||||||
|
{ participant_hash_2: { _eq: participantHash1 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
return response.data?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConversationStatus(id, status) {
|
||||||
|
const response = await this.patch(`/items/conversations/${id}`, { status })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Favorites (Favoriten) ====================
|
// ==================== Favorites (Favoriten) ====================
|
||||||
|
|
||||||
async getFavorites() {
|
async getFavorites() {
|
||||||
|
|||||||
234
js/services/listings.js
Normal file
234
js/services/listings.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Listings Service - Higher-level API for listing operations
|
||||||
|
* Wraps directus.js with business logic and convenience methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { directus } from './directus.js'
|
||||||
|
import { currencyService } from './currency.js'
|
||||||
|
|
||||||
|
class ListingsService {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new Map()
|
||||||
|
this.cacheTimeout = 5 * 60 * 1000 // 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeaturedListings(limit = 8) {
|
||||||
|
return directus.getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' }
|
||||||
|
},
|
||||||
|
sort: ['-views', '-date_created'],
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentListings(limit = 12) {
|
||||||
|
return directus.getListings({
|
||||||
|
filter: { status: { _eq: 'published' } },
|
||||||
|
sort: ['-date_created'],
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListingWithPriceConversion(id, targetCurrency = 'EUR') {
|
||||||
|
const listing = await directus.getListing(id)
|
||||||
|
if (!listing) return null
|
||||||
|
|
||||||
|
if (listing.price && listing.price_mode === 'xmr') {
|
||||||
|
const fiatPrice = await currencyService.xmrToFiat(listing.price, targetCurrency)
|
||||||
|
listing.price_converted = fiatPrice
|
||||||
|
listing.price_converted_currency = targetCurrency
|
||||||
|
} else if (listing.price && listing.currency === 'XMR') {
|
||||||
|
const fiatPrice = await currencyService.xmrToFiat(listing.price, targetCurrency)
|
||||||
|
listing.price_converted = fiatPrice
|
||||||
|
listing.price_converted_currency = targetCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
return listing
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListingsWithFilters(filters = {}) {
|
||||||
|
const directusFilter = { status: { _eq: 'published' } }
|
||||||
|
|
||||||
|
if (filters.category) {
|
||||||
|
directusFilter.category = { _eq: filters.category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.location) {
|
||||||
|
directusFilter.location = { _eq: filters.location }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minPrice !== undefined) {
|
||||||
|
directusFilter.price = directusFilter.price || {}
|
||||||
|
directusFilter.price._gte = filters.minPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.maxPrice !== undefined) {
|
||||||
|
directusFilter.price = directusFilter.price || {}
|
||||||
|
directusFilter.price._lte = filters.maxPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.condition) {
|
||||||
|
directusFilter.condition = { _eq: filters.condition }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.shipping === true) {
|
||||||
|
directusFilter.shipping = { _eq: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.priceType) {
|
||||||
|
directusFilter.price_type = { _eq: filters.priceType }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortMap = {
|
||||||
|
'newest': ['-date_created'],
|
||||||
|
'oldest': ['date_created'],
|
||||||
|
'price_asc': ['price'],
|
||||||
|
'price_desc': ['-price'],
|
||||||
|
'views': ['-views']
|
||||||
|
}
|
||||||
|
|
||||||
|
return directus.getListings({
|
||||||
|
filter: directusFilter,
|
||||||
|
sort: sortMap[filters.sort] || ['-date_created'],
|
||||||
|
limit: filters.limit || 20,
|
||||||
|
page: filters.page || 1,
|
||||||
|
search: filters.search
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSimilarListings(listing, limit = 4) {
|
||||||
|
if (!listing) return []
|
||||||
|
|
||||||
|
const response = await directus.getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
id: { _neq: listing.id },
|
||||||
|
category: { _eq: listing.category?.id || listing.category }
|
||||||
|
},
|
||||||
|
limit
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.items || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserListings(userId) {
|
||||||
|
const response = await directus.get('/items/listings', {
|
||||||
|
fields: ['*', 'images.directus_files_id.id', 'category.name', 'location.name'],
|
||||||
|
filter: { user_created: { _eq: userId } },
|
||||||
|
sort: ['-date_created']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async createListingWithImages(data, imageFiles) {
|
||||||
|
let images = []
|
||||||
|
|
||||||
|
if (imageFiles && imageFiles.length > 0) {
|
||||||
|
const uploadedFiles = await directus.uploadMultipleFiles(imageFiles)
|
||||||
|
images = uploadedFiles.map((file, index) => ({
|
||||||
|
directus_files_id: file.id,
|
||||||
|
sort: index
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingData = {
|
||||||
|
...data,
|
||||||
|
status: 'draft',
|
||||||
|
images: images.length > 0 ? { create: images } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return directus.createListing(listingData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishListing(id) {
|
||||||
|
return directus.updateListing(id, { status: 'published' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async unpublishListing(id) {
|
||||||
|
return directus.updateListing(id, { status: 'draft' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveListing(id) {
|
||||||
|
return directus.updateListing(id, { status: 'archived' })
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPrice(listing, locale = 'de-DE') {
|
||||||
|
if (!listing || listing.price === null || listing.price === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.price_type === 'negotiable') {
|
||||||
|
return `${this.formatAmount(listing.price, listing.currency, locale)} VB`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing.price_type === 'free') {
|
||||||
|
return 'Gratis'
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatAmount(listing.price, listing.currency, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAmount(amount, currency = 'EUR', locale = 'de-DE') {
|
||||||
|
if (currency === 'XMR') {
|
||||||
|
return `${parseFloat(amount).toFixed(4)} XMR`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
getConditionLabel(condition, lang = 'de') {
|
||||||
|
const labels = {
|
||||||
|
de: {
|
||||||
|
new: 'Neu',
|
||||||
|
like_new: 'Wie neu',
|
||||||
|
good: 'Gut',
|
||||||
|
fair: 'Akzeptabel',
|
||||||
|
poor: 'Stark gebraucht'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
new: 'New',
|
||||||
|
like_new: 'Like new',
|
||||||
|
good: 'Good',
|
||||||
|
fair: 'Fair',
|
||||||
|
poor: 'Poor'
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
new: 'Neuf',
|
||||||
|
like_new: 'Comme neuf',
|
||||||
|
good: 'Bon',
|
||||||
|
fair: 'Acceptable',
|
||||||
|
poor: 'Usagé'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels[lang]?.[condition] || condition
|
||||||
|
}
|
||||||
|
|
||||||
|
getPriceTypeLabel(priceType, lang = 'de') {
|
||||||
|
const labels = {
|
||||||
|
de: {
|
||||||
|
fixed: 'Festpreis',
|
||||||
|
negotiable: 'Verhandelbar',
|
||||||
|
free: 'Zu verschenken'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
fixed: 'Fixed price',
|
||||||
|
negotiable: 'Negotiable',
|
||||||
|
free: 'Free'
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
fixed: 'Prix fixe',
|
||||||
|
negotiable: 'Négociable',
|
||||||
|
free: 'Gratuit'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels[lang]?.[priceType] || priceType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listingsService = new ListingsService()
|
||||||
156
js/services/locations.js
Normal file
156
js/services/locations.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Locations Service - Handles location data and geosearch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { directus } from './directus.js'
|
||||||
|
|
||||||
|
class LocationsService {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new Map()
|
||||||
|
this.cacheTimeout = 30 * 60 * 1000 // 30 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const cacheKey = 'all_locations'
|
||||||
|
const cached = this.getFromCache(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const locations = await directus.getLocations()
|
||||||
|
this.setCache(cacheKey, locations)
|
||||||
|
return locations
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
return directus.getLocation(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
if (!query || query.length < 2) return []
|
||||||
|
return directus.searchLocations(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByRegion(region) {
|
||||||
|
const cacheKey = `region_${region}`
|
||||||
|
const cached = this.getFromCache(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const locations = await directus.getLocationsByRegion(region)
|
||||||
|
this.setCache(cacheKey, locations)
|
||||||
|
return locations
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByCountry(country) {
|
||||||
|
const cacheKey = `country_${country}`
|
||||||
|
const cached = this.getFromCache(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const locations = await directus.getLocationsByCountry(country)
|
||||||
|
this.setCache(cacheKey, locations)
|
||||||
|
return locations
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRegions(country = 'DE') {
|
||||||
|
const locations = await this.getByCountry(country)
|
||||||
|
const regions = [...new Set(locations.map(l => l.region).filter(Boolean))]
|
||||||
|
return regions.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNearby(latitude, longitude, radiusKm = 50, limit = 10) {
|
||||||
|
const locations = await this.getAll()
|
||||||
|
|
||||||
|
const withDistance = locations
|
||||||
|
.filter(loc => loc.latitude && loc.longitude)
|
||||||
|
.map(loc => ({
|
||||||
|
...loc,
|
||||||
|
distance: this.calculateDistance(latitude, longitude, loc.latitude, loc.longitude)
|
||||||
|
}))
|
||||||
|
.filter(loc => loc.distance <= radiusKm)
|
||||||
|
.sort((a, b) => a.distance - b.distance)
|
||||||
|
.slice(0, limit)
|
||||||
|
|
||||||
|
return withDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371 // Earth radius in km
|
||||||
|
const dLat = this.toRad(lat2 - lat1)
|
||||||
|
const dLon = this.toRad(lon2 - lon1)
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
return R * c
|
||||||
|
}
|
||||||
|
|
||||||
|
toRad(deg) {
|
||||||
|
return deg * (Math.PI / 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatLocation(location) {
|
||||||
|
if (!location) return ''
|
||||||
|
|
||||||
|
const parts = [location.name]
|
||||||
|
if (location.postal_code) parts.unshift(location.postal_code)
|
||||||
|
if (location.region && location.region !== location.name) {
|
||||||
|
parts.push(location.region)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDistance(distanceKm) {
|
||||||
|
if (distanceKm < 1) {
|
||||||
|
return `${Math.round(distanceKm * 1000)} m`
|
||||||
|
}
|
||||||
|
return `${distanceKm.toFixed(1)} km`
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentLocation() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
reject(new Error('Geolocation not supported'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
pos => resolve({
|
||||||
|
latitude: pos.coords.latitude,
|
||||||
|
longitude: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy
|
||||||
|
}),
|
||||||
|
err => reject(err),
|
||||||
|
{ enableHighAccuracy: false, timeout: 10000 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async findNearestLocation() {
|
||||||
|
try {
|
||||||
|
const coords = await this.getCurrentLocation()
|
||||||
|
const nearby = await this.getNearby(coords.latitude, coords.longitude, 100, 1)
|
||||||
|
return nearby[0] || null
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not get location:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFromCache(key) {
|
||||||
|
const cached = this.cache.get(key)
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(key, data) {
|
||||||
|
this.cache.set(key, { data, timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsService = new LocationsService()
|
||||||
@@ -143,7 +143,11 @@
|
|||||||
"moneroHint": "Käufer senden die Zahlung direkt an diese Adresse.",
|
"moneroHint": "Käufer senden die Zahlung direkt an diese Adresse.",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"publish": "Veröffentlichen",
|
"publish": "Veröffentlichen",
|
||||||
"publishing": "Wird veröffentlicht..."
|
"publishing": "Wird veröffentlicht...",
|
||||||
|
"publishFailed": "Veröffentlichung fehlgeschlagen. Bitte versuche es erneut.",
|
||||||
|
"invalidMoneroAddress": "Ungültige Monero-Adresse. Bitte prüfe das Format.",
|
||||||
|
"draftRestored": "Entwurf wiederhergestellt",
|
||||||
|
"clearDraft": "Verwerfen"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Seite nicht gefunden",
|
"title": "Seite nicht gefunden",
|
||||||
|
|||||||
@@ -143,7 +143,11 @@
|
|||||||
"moneroHint": "Buyers will send payment directly to this address.",
|
"moneroHint": "Buyers will send payment directly to this address.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
"publishing": "Publishing..."
|
"publishing": "Publishing...",
|
||||||
|
"publishFailed": "Publishing failed. Please try again.",
|
||||||
|
"invalidMoneroAddress": "Invalid Monero address. Please check the format.",
|
||||||
|
"draftRestored": "Draft restored",
|
||||||
|
"clearDraft": "Discard"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Page Not Found",
|
"title": "Page Not Found",
|
||||||
|
|||||||
@@ -143,7 +143,11 @@
|
|||||||
"moneroHint": "Les acheteurs envoient le paiement directement à cette adresse.",
|
"moneroHint": "Les acheteurs envoient le paiement directement à cette adresse.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"publish": "Publier",
|
"publish": "Publier",
|
||||||
"publishing": "Publication en cours..."
|
"publishing": "Publication en cours...",
|
||||||
|
"publishFailed": "La publication a échoué. Veuillez réessayer.",
|
||||||
|
"invalidMoneroAddress": "Adresse Monero invalide. Veuillez vérifier le format.",
|
||||||
|
"draftRestored": "Brouillon restauré",
|
||||||
|
"clearDraft": "Supprimer"
|
||||||
},
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Page non trouvée",
|
"title": "Page non trouvée",
|
||||||
|
|||||||
Reference in New Issue
Block a user