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 { SUPPORTED_CURRENCIES } from '../../services/currency.js'
|
||||
|
||||
const STORAGE_KEY = 'dgray_create_draft'
|
||||
|
||||
class PageCreate extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.formData = {
|
||||
this.formData = this.loadDraft() || {
|
||||
title: '',
|
||||
description: '',
|
||||
price: '',
|
||||
@@ -26,6 +28,27 @@ class PageCreate extends HTMLElement {
|
||||
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() {
|
||||
// Check if logged in
|
||||
if (!auth.isLoggedIn()) {
|
||||
@@ -33,6 +56,7 @@ class PageCreate extends HTMLElement {
|
||||
return
|
||||
}
|
||||
|
||||
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
|
||||
await this.loadCategories()
|
||||
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() {
|
||||
if (this.unsubscribe) this.unsubscribe()
|
||||
}
|
||||
@@ -76,6 +111,13 @@ class PageCreate extends HTMLElement {
|
||||
<div class="create-page">
|
||||
<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">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
${this.categories.map(cat => `
|
||||
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
|
||||
${cat.name}
|
||||
${t(`categories.${cat.slug}`) || cat.name}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
@@ -237,6 +279,7 @@ class PageCreate extends HTMLElement {
|
||||
input.addEventListener('input', (e) => {
|
||||
if (e.target.name) {
|
||||
this.formData[e.target.name] = e.target.value
|
||||
this.saveDraft()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -245,9 +288,31 @@ class PageCreate extends HTMLElement {
|
||||
const shippingCheckbox = this.querySelector('#shipping')
|
||||
shippingCheckbox?.addEventListener('change', (e) => {
|
||||
this.formData.shipping = e.target.checked
|
||||
this.saveDraft()
|
||||
})
|
||||
|
||||
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) {
|
||||
@@ -311,7 +376,15 @@ class PageCreate extends HTMLElement {
|
||||
e.preventDefault()
|
||||
|
||||
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.clearError()
|
||||
|
||||
const form = e.target
|
||||
const submitBtn = form.querySelector('[type="submit"]')
|
||||
@@ -329,43 +402,80 @@ class PageCreate extends HTMLElement {
|
||||
// Create listing
|
||||
const listingData = {
|
||||
title: this.formData.title,
|
||||
slug: this.generateSlug(this.formData.title),
|
||||
description: this.formData.description,
|
||||
price: parseFloat(this.formData.price) || 0,
|
||||
price: String(parseFloat(this.formData.price) || 0),
|
||||
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'
|
||||
}
|
||||
|
||||
// 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
|
||||
if (imageIds.length > 0) {
|
||||
listingData.images = imageIds.map((id, index) => ({
|
||||
directus_files_id: id,
|
||||
sort: index
|
||||
}))
|
||||
listingData.images = imageIds
|
||||
}
|
||||
|
||||
console.log('Creating listing:', 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) {
|
||||
console.error('Failed to create listing:', error)
|
||||
console.error('Error details:', JSON.stringify(error.data, null, 2))
|
||||
submitBtn.disabled = false
|
||||
submitBtn.textContent = t('create.publish')
|
||||
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) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
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)
|
||||
@@ -508,5 +618,42 @@ style.textContent = /* css */`
|
||||
justify-content: flex-end;
|
||||
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)
|
||||
|
||||
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',
|
||||
'category.id',
|
||||
'category.name',
|
||||
'user_created.id',
|
||||
'user_created.first_name'
|
||||
'category.slug',
|
||||
'category.icon',
|
||||
'location.id',
|
||||
'location.name',
|
||||
'location.region',
|
||||
'user_created.id'
|
||||
],
|
||||
filter: options.filter || { status: { _eq: 'published' } },
|
||||
sort: options.sort || ['-date_created'],
|
||||
@@ -280,9 +284,9 @@ class DirectusService {
|
||||
'*',
|
||||
'images.directus_files_id.*',
|
||||
'category.*',
|
||||
'category.translations.*',
|
||||
'location.*',
|
||||
'user_created.id',
|
||||
'user_created.first_name',
|
||||
'user_created.avatar',
|
||||
'user_created.date_created'
|
||||
]
|
||||
})
|
||||
@@ -291,7 +295,7 @@ class DirectusService {
|
||||
|
||||
async createListing(data) {
|
||||
const response = await this.post('/items/listings', data)
|
||||
return response.data
|
||||
return response?.data || response
|
||||
}
|
||||
|
||||
async updateListing(id, data) {
|
||||
@@ -305,7 +309,7 @@ class DirectusService {
|
||||
|
||||
async getMyListings() {
|
||||
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' } },
|
||||
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) ====================
|
||||
|
||||
async getCategories() {
|
||||
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'],
|
||||
limit: -1
|
||||
})
|
||||
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() {
|
||||
const categories = await this.getCategories()
|
||||
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', {
|
||||
fields: [
|
||||
'*',
|
||||
'listing.id',
|
||||
'listing.title',
|
||||
'listing.images.directus_files_id.id',
|
||||
'buyer.id',
|
||||
'buyer.first_name',
|
||||
'seller.id',
|
||||
'seller.first_name',
|
||||
'messages.*'
|
||||
'listing_id.id',
|
||||
'listing_id.title',
|
||||
'listing_id.images.directus_files_id.id'
|
||||
],
|
||||
filter: {
|
||||
_or: [
|
||||
{ buyer: { _eq: '$CURRENT_USER' } },
|
||||
{ seller: { _eq: '$CURRENT_USER' } }
|
||||
{ participant_hash_1: { _eq: participantHash } },
|
||||
{ participant_hash_2: { _eq: participantHash } }
|
||||
]
|
||||
},
|
||||
sort: ['-date_updated']
|
||||
@@ -374,35 +474,74 @@ class DirectusService {
|
||||
const response = await this.get(`/items/conversations/${id}`, {
|
||||
fields: [
|
||||
'*',
|
||||
'listing.*',
|
||||
'listing.images.directus_files_id.*',
|
||||
'buyer.*',
|
||||
'seller.*',
|
||||
'messages.*',
|
||||
'messages.sender.*'
|
||||
'listing_id.*',
|
||||
'listing_id.images.directus_files_id.*'
|
||||
]
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async sendMessage(conversationId, content) {
|
||||
const response = await this.post('/items/messages', {
|
||||
conversation: conversationId,
|
||||
content
|
||||
async getConversationMessages(conversationId) {
|
||||
const response = await this.get('/items/messages', {
|
||||
fields: ['*'],
|
||||
filter: { conversation: { _eq: conversationId } },
|
||||
sort: ['date_created']
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async startConversation(listingId, message) {
|
||||
const response = await this.post('/items/conversations', {
|
||||
listing: listingId,
|
||||
messages: {
|
||||
create: [{ content: message }]
|
||||
}
|
||||
async sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') {
|
||||
const response = await this.post('/items/messages', {
|
||||
conversation: conversationId,
|
||||
sender_hash: senderHash,
|
||||
content_encrypted: encryptedContent,
|
||||
nonce: nonce,
|
||||
type: type
|
||||
})
|
||||
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) ====================
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user