From b9462b040dc6e79e0dafed394929410d4dcb1ab3 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sat, 31 Jan 2026 17:01:04 +0100 Subject: [PATCH] add new services for listings, conversations, locations and categories --- js/components/pages/page-create.js | 177 ++++++++++++++++++++-- js/services/categories.js | 153 +++++++++++++++++++ js/services/conversations.js | 206 +++++++++++++++++++++++++ js/services/directus.js | 209 +++++++++++++++++++++----- js/services/listings.js | 234 +++++++++++++++++++++++++++++ js/services/locations.js | 156 +++++++++++++++++++ locales/de.json | 6 +- locales/en.json | 6 +- locales/fr.json | 6 +- 9 files changed, 1100 insertions(+), 53 deletions(-) create mode 100644 js/services/categories.js create mode 100644 js/services/conversations.js create mode 100644 js/services/listings.js create mode 100644 js/services/locations.js diff --git a/js/components/pages/page-create.js b/js/components/pages/page-create.js index fd12648..e08f158 100644 --- a/js/components/pages/page-create.js +++ b/js/components/pages/page-create.js @@ -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 {

${t('create.title')}

+ ${this.hasDraft ? ` +
+ ${t('create.draftRestored')} + +
+ ` : ''} +
@@ -97,7 +139,7 @@ class PageCreate extends HTMLElement { ${this.categories.map(cat => ` `).join('')} @@ -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) diff --git a/js/services/categories.js b/js/services/categories.js new file mode 100644 index 0000000..bc3fb66 --- /dev/null +++ b/js/services/categories.js @@ -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() diff --git a/js/services/conversations.js b/js/services/conversations.js new file mode 100644 index 0000000..2da5c3b --- /dev/null +++ b/js/services/conversations.js @@ -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() diff --git a/js/services/directus.js b/js/services/directus.js index 3a576ca..cb1d8ed 100644 --- a/js/services/directus.js +++ b/js/services/directus.js @@ -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() { diff --git a/js/services/listings.js b/js/services/listings.js new file mode 100644 index 0000000..06c2743 --- /dev/null +++ b/js/services/listings.js @@ -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() diff --git a/js/services/locations.js b/js/services/locations.js new file mode 100644 index 0000000..f961e45 --- /dev/null +++ b/js/services/locations.js @@ -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() diff --git a/locales/de.json b/locales/de.json index cf3de17..2d7206a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 5bb4639..bab0e2d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index e94f638..ff35090 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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",