add new services for listings, conversations, locations and categories

This commit is contained in:
2026-01-31 17:01:04 +01:00
parent afb6cf39da
commit b9462b040d
9 changed files with 1100 additions and 53 deletions

View File

@@ -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
View 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()

View 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()

View File

@@ -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
View 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
View 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()

View File

@@ -143,7 +143,11 @@
"moneroHint": "Käufer senden die Zahlung direkt an diese Adresse.",
"cancel": "Abbrechen",
"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": {
"title": "Seite nicht gefunden",

View File

@@ -143,7 +143,11 @@
"moneroHint": "Buyers will send payment directly to this address.",
"cancel": "Cancel",
"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": {
"title": "Page Not Found",

View File

@@ -143,7 +143,11 @@
"moneroHint": "Les acheteurs envoient le paiement directement à cette adresse.",
"cancel": "Annuler",
"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": {
"title": "Page non trouvée",