refactor: modularize directus.js into 8 focused submodules with backward-compatible facade
This commit is contained in:
@@ -1,850 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* Directus API Service for dgray.io
|
* Directus API Service for dgray.io — Facade
|
||||||
* Connects to https://api.dgray.io/
|
* Re-exports modular sub-services as a single backward-compatible singleton.
|
||||||
* @module services/directus
|
* @module services/directus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {string} */
|
import { client, DirectusError } from './directus/client.js'
|
||||||
const DIRECTUS_URL = 'https://api.dgray.io'
|
import * as authApi from './directus/auth.js'
|
||||||
|
import * as listingsApi from './directus/listings.js'
|
||||||
/**
|
import * as categoriesApi from './directus/categories.js'
|
||||||
* @typedef {Object} AuthTokens
|
import * as locationsApi from './directus/locations.js'
|
||||||
* @property {string} access_token - JWT access token
|
import * as conversationsApi from './directus/conversations.js'
|
||||||
* @property {string} refresh_token - Refresh token for session renewal
|
import * as filesApi from './directus/files.js'
|
||||||
* @property {number} expires - Token expiry in seconds
|
import * as notificationsApi from './directus/notifications.js'
|
||||||
*/
|
|
||||||
|
const directus = {
|
||||||
/**
|
// ── Client / Token ──
|
||||||
* @typedef {Object} Listing
|
get baseUrl() { return client.baseUrl },
|
||||||
* @property {string} id - UUID
|
get accessToken() { return client.accessToken },
|
||||||
* @property {string} title - Listing title
|
isAuthenticated: () => client.isAuthenticated(),
|
||||||
* @property {string} [description] - Listing description
|
clearTokens: () => client.clearTokens(),
|
||||||
* @property {number} price - Price value
|
|
||||||
* @property {string} currency - Currency code (EUR, USD, CHF, XMR)
|
// ── Generic HTTP (used by external services like favorites) ──
|
||||||
* @property {string} status - Status (draft, published, sold, expired)
|
get: (endpoint, params) => client.get(endpoint, params),
|
||||||
* @property {Object} [location] - Location object
|
post: (endpoint, data) => client.post(endpoint, data),
|
||||||
* @property {Array} [images] - Array of image objects
|
patch: (endpoint, data) => client.patch(endpoint, data),
|
||||||
*/
|
delete: (endpoint) => client.delete(endpoint),
|
||||||
|
|
||||||
/**
|
// ── Auth ──
|
||||||
* @typedef {Object} Category
|
login: authApi.login,
|
||||||
* @property {string} id - UUID
|
logout: authApi.logout,
|
||||||
* @property {string} slug - URL-safe slug
|
register: authApi.register,
|
||||||
* @property {string} [parent] - Parent category ID
|
requestPasswordReset: authApi.requestPasswordReset,
|
||||||
* @property {Array} [translations] - Category translations
|
resetPassword: authApi.resetPassword,
|
||||||
*/
|
getCurrentUser: authApi.getCurrentUser,
|
||||||
|
updateCurrentUser: authApi.updateCurrentUser,
|
||||||
/**
|
|
||||||
* Main Directus API client
|
// ── Listings ──
|
||||||
* @class
|
getListings: listingsApi.getListings,
|
||||||
*/
|
getListing: listingsApi.getListing,
|
||||||
class DirectusService {
|
createListing: listingsApi.createListing,
|
||||||
constructor() {
|
updateListing: listingsApi.updateListing,
|
||||||
this.baseUrl = DIRECTUS_URL
|
incrementListingViews: listingsApi.incrementListingViews,
|
||||||
this.accessToken = null
|
deleteListing: listingsApi.deleteListing,
|
||||||
this.refreshToken = null
|
getMyListings: listingsApi.getMyListings,
|
||||||
this.tokenExpiry = null
|
searchListings: listingsApi.searchListings,
|
||||||
this.refreshTimeout = null
|
getListingsByCategory: listingsApi.getListingsByCategory,
|
||||||
this._refreshPromise = null
|
getListingsByLocation: listingsApi.getListingsByLocation,
|
||||||
|
|
||||||
this.loadTokens()
|
// ── Categories ──
|
||||||
this.setupVisibilityRefresh()
|
getCategories: categoriesApi.getCategories,
|
||||||
}
|
getCategory: categoriesApi.getCategory,
|
||||||
|
getCategoryTree: categoriesApi.getCategoryTree,
|
||||||
// ==================== Token Management ====================
|
buildCategoryTree: categoriesApi.buildCategoryTree,
|
||||||
|
getSubcategories: categoriesApi.getSubcategories,
|
||||||
loadTokens() {
|
|
||||||
const stored = localStorage.getItem('dgray_auth')
|
// ── Locations ──
|
||||||
if (stored) {
|
getLocations: locationsApi.getLocations,
|
||||||
try {
|
getLocation: locationsApi.getLocation,
|
||||||
const { accessToken, refreshToken, expiry } = JSON.parse(stored)
|
searchLocations: locationsApi.searchLocations,
|
||||||
this.accessToken = accessToken
|
getLocationsByRegion: locationsApi.getLocationsByRegion,
|
||||||
this.refreshToken = refreshToken
|
getLocationsByCountry: locationsApi.getLocationsByCountry,
|
||||||
this.tokenExpiry = expiry
|
|
||||||
this.scheduleTokenRefresh()
|
// ── Conversations ──
|
||||||
} catch (e) {
|
getConversations: conversationsApi.getConversations,
|
||||||
this.clearTokens()
|
getConversation: conversationsApi.getConversation,
|
||||||
}
|
getConversationMessages: conversationsApi.getConversationMessages,
|
||||||
}
|
sendMessage: conversationsApi.sendMessage,
|
||||||
}
|
startConversation: conversationsApi.startConversation,
|
||||||
|
findConversation: conversationsApi.findConversation,
|
||||||
saveTokens(accessToken, refreshToken, expiresIn) {
|
updateConversationStatus: conversationsApi.updateConversationStatus,
|
||||||
this.accessToken = accessToken
|
|
||||||
this.refreshToken = refreshToken
|
// ── Files ──
|
||||||
this.tokenExpiry = Date.now() + (expiresIn * 1000)
|
uploadFile: filesApi.uploadFile,
|
||||||
|
uploadMultipleFiles: filesApi.uploadMultipleFiles,
|
||||||
localStorage.setItem('dgray_auth', JSON.stringify({
|
getFileUrl: filesApi.getFileUrl,
|
||||||
accessToken: this.accessToken,
|
getThumbnailUrl: filesApi.getThumbnailUrl,
|
||||||
refreshToken: this.refreshToken,
|
|
||||||
expiry: this.tokenExpiry
|
|
||||||
}))
|
|
||||||
|
|
||||||
this.scheduleTokenRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTokens() {
|
|
||||||
this.accessToken = null
|
|
||||||
this.refreshToken = null
|
|
||||||
this.tokenExpiry = null
|
|
||||||
localStorage.removeItem('dgray_auth')
|
|
||||||
|
|
||||||
if (this.refreshTimeout) {
|
|
||||||
clearTimeout(this.refreshTimeout)
|
|
||||||
this.refreshTimeout = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleTokenRefresh() {
|
|
||||||
if (this.refreshTimeout) {
|
|
||||||
clearTimeout(this.refreshTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.tokenExpiry || !this.refreshToken) return
|
|
||||||
|
|
||||||
// Refresh 1 minute before expiry
|
|
||||||
const refreshIn = this.tokenExpiry - Date.now() - 60000
|
|
||||||
|
|
||||||
if (refreshIn > 0) {
|
|
||||||
this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn)
|
|
||||||
} else if (this.refreshToken) {
|
|
||||||
this.refreshSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupVisibilityRefresh() {
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'visible' && this.refreshToken) {
|
|
||||||
const timeLeft = this.tokenExpiry - Date.now()
|
|
||||||
if (timeLeft < 120000) {
|
|
||||||
this.refreshSession()
|
|
||||||
}
|
|
||||||
this.scheduleTokenRefresh()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
isAuthenticated() {
|
|
||||||
return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== HTTP Methods ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make an HTTP request to the Directus API
|
|
||||||
* @param {string} endpoint - API endpoint path
|
|
||||||
* @param {RequestInit} [options={}] - Fetch options
|
|
||||||
* @returns {Promise<Object|null>} Response data or null for 204
|
|
||||||
* @throws {DirectusError} On request failure
|
|
||||||
*/
|
|
||||||
async request(endpoint, options = {}, _retryCount = 0) {
|
|
||||||
const url = `${this.baseUrl}${endpoint}`
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.accessToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${this.accessToken}`
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) {
|
|
||||||
if (!this._refreshPromise) {
|
|
||||||
this._refreshPromise = this.refreshSession().finally(() => {
|
|
||||||
this._refreshPromise = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const refreshed = await this._refreshPromise
|
|
||||||
if (!refreshed) {
|
|
||||||
this.clearTokens()
|
|
||||||
}
|
|
||||||
return this.request(endpoint, options, _retryCount + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429 && _retryCount < 3) {
|
|
||||||
const retryAfterHeader = response.headers.get('Retry-After') || '1'
|
|
||||||
let waitMs
|
|
||||||
if (parseInt(retryAfterHeader, 10) > 100) {
|
|
||||||
waitMs = parseInt(retryAfterHeader, 10)
|
|
||||||
} else {
|
|
||||||
waitMs = parseInt(retryAfterHeader, 10) * 1000
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, waitMs))
|
|
||||||
return this.request(endpoint, options, _retryCount + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({}))
|
|
||||||
throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DirectusError) throw error
|
|
||||||
throw new DirectusError(0, 'Network error', { originalError: error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET request with query parameters
|
|
||||||
* @param {string} endpoint - API endpoint
|
|
||||||
* @param {Object} [params={}] - Query parameters
|
|
||||||
* @returns {Promise<Object>} Response data
|
|
||||||
*/
|
|
||||||
async get(endpoint, params = {}) {
|
|
||||||
const queryString = this.buildQueryString(params)
|
|
||||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint
|
|
||||||
return this.request(url, { method: 'GET' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST request with JSON body
|
|
||||||
* @param {string} endpoint - API endpoint
|
|
||||||
* @param {Object} data - Request body
|
|
||||||
* @returns {Promise<Object>} Response data
|
|
||||||
*/
|
|
||||||
async post(endpoint, data) {
|
|
||||||
return this.request(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH request with JSON body
|
|
||||||
* @param {string} endpoint - API endpoint
|
|
||||||
* @param {Object} data - Request body
|
|
||||||
* @returns {Promise<Object>} Response data
|
|
||||||
*/
|
|
||||||
async patch(endpoint, data) {
|
|
||||||
return this.request(endpoint, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(endpoint) {
|
|
||||||
return this.request(endpoint, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
buildQueryString(params) {
|
|
||||||
const searchParams = new URLSearchParams()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value === undefined || value === null) continue
|
|
||||||
|
|
||||||
if (key === 'sort' && Array.isArray(value)) {
|
|
||||||
searchParams.set(key, value.join(','))
|
|
||||||
} else if (key === 'fields' && Array.isArray(value)) {
|
|
||||||
searchParams.set(key, value.join(','))
|
|
||||||
} else if (typeof value === 'object') {
|
|
||||||
searchParams.set(key, JSON.stringify(value))
|
|
||||||
} else {
|
|
||||||
searchParams.set(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchParams.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Authentication ====================
|
|
||||||
|
|
||||||
async login(email, password) {
|
|
||||||
const response = await this.post('/auth/login', { email, password })
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
this.saveTokens(
|
|
||||||
response.data.access_token,
|
|
||||||
response.data.refresh_token,
|
|
||||||
response.data.expires
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
if (this.refreshToken) {
|
|
||||||
try {
|
|
||||||
await this.post('/auth/logout', { refresh_token: this.refreshToken })
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors - tokens will be cleared anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.clearTokens()
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshSession(_retryCount = 0) {
|
|
||||||
if (!this.refreshToken) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
refresh_token: this.refreshToken,
|
|
||||||
mode: 'json'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Refresh failed')
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.data) {
|
|
||||||
this.saveTokens(
|
|
||||||
result.data.access_token,
|
|
||||||
result.data.refresh_token,
|
|
||||||
result.data.expires
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (_retryCount < 2) {
|
|
||||||
await new Promise(r => setTimeout(r, 2000))
|
|
||||||
return this.refreshSession(_retryCount + 1)
|
|
||||||
}
|
|
||||||
this.clearTokens()
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async register(email, password) {
|
|
||||||
// Public registration (no verification required)
|
|
||||||
return this.post('/users/register', { email, password })
|
|
||||||
}
|
|
||||||
|
|
||||||
async requestPasswordReset(email) {
|
|
||||||
return this.post('/auth/password/request', { email })
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetPassword(token, password) {
|
|
||||||
return this.post('/auth/password/reset', { token, password })
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCurrentUser() {
|
|
||||||
const response = await this.get('/users/me', {
|
|
||||||
fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status', 'preferred_currency', 'preferred_locale']
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCurrentUser(data) {
|
|
||||||
const response = await this.patch('/users/me', data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Listings (Anzeigen) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get listings with filters and pagination
|
|
||||||
* @param {Object} [options={}] - Query options
|
|
||||||
* @param {number} [options.limit=20] - Max items to return
|
|
||||||
* @param {number} [options.page=1] - Page number
|
|
||||||
* @param {string} [options.category] - Filter by category slug
|
|
||||||
* @param {string} [options.search] - Search query
|
|
||||||
* @returns {Promise<{items: Listing[], total: number}>} Paginated listings
|
|
||||||
*/
|
|
||||||
async getListings(options = {}) {
|
|
||||||
const params = {
|
|
||||||
fields: options.fields || [
|
|
||||||
'id',
|
|
||||||
'status',
|
|
||||||
'title',
|
|
||||||
'slug',
|
|
||||||
'price',
|
|
||||||
'currency',
|
|
||||||
'condition',
|
|
||||||
'expires_at',
|
|
||||||
'date_created',
|
|
||||||
'user_created',
|
|
||||||
'images.directus_files_id.id',
|
|
||||||
'category.id',
|
|
||||||
'category.name',
|
|
||||||
'category.slug',
|
|
||||||
'category.icon',
|
|
||||||
'location.id',
|
|
||||||
'location.name',
|
|
||||||
'location.postal_code',
|
|
||||||
'location.country',
|
|
||||||
'location.latitude',
|
|
||||||
'location.longitude'
|
|
||||||
],
|
|
||||||
filter: options.filter || {
|
|
||||||
status: { _eq: 'published' },
|
|
||||||
_or: [
|
|
||||||
{ expires_at: { _null: true } },
|
|
||||||
{ expires_at: { _gt: '$NOW' } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
sort: options.sort || ['-date_created'],
|
|
||||||
limit: options.limit || 20,
|
|
||||||
page: options.page || 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.search) {
|
|
||||||
params.search = options.search
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.get('/items/listings', params)
|
|
||||||
return {
|
|
||||||
items: response.data,
|
|
||||||
meta: response.meta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single listing by ID
|
|
||||||
* @param {string} id - Listing UUID
|
|
||||||
* @returns {Promise<Listing|null>} Listing object or null
|
|
||||||
*/
|
|
||||||
async getListing(id) {
|
|
||||||
const response = await this.get(`/items/listings/${id}`, {
|
|
||||||
fields: [
|
|
||||||
'id',
|
|
||||||
'status',
|
|
||||||
'title',
|
|
||||||
'slug',
|
|
||||||
'description',
|
|
||||||
'price',
|
|
||||||
'currency',
|
|
||||||
'price_mode',
|
|
||||||
'price_type',
|
|
||||||
'condition',
|
|
||||||
'shipping',
|
|
||||||
'shipping_cost',
|
|
||||||
'views',
|
|
||||||
'paid_at',
|
|
||||||
'payment_status',
|
|
||||||
'expires_at',
|
|
||||||
'date_created',
|
|
||||||
'user_created',
|
|
||||||
'images.directus_files_id.id',
|
|
||||||
'category.id',
|
|
||||||
'category.name',
|
|
||||||
'category.slug',
|
|
||||||
'category.translations.*',
|
|
||||||
'location.id',
|
|
||||||
'location.name',
|
|
||||||
'location.postal_code',
|
|
||||||
'location.country'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new listing
|
|
||||||
* @param {Object} data - Listing data
|
|
||||||
* @returns {Promise<Listing>} Created listing
|
|
||||||
*/
|
|
||||||
async createListing(data) {
|
|
||||||
const response = await this.post('/items/listings', data)
|
|
||||||
return response?.data || response
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateListing(id, data) {
|
|
||||||
const response = await this.patch(`/items/listings/${id}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async incrementListingViews(id) {
|
|
||||||
// Check if user already viewed this listing (session-based)
|
|
||||||
const viewedKey = `dgray_viewed_${id}`
|
|
||||||
if (sessionStorage.getItem(viewedKey)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get current views
|
|
||||||
const listing = await this.get(`/items/listings/${id}`, {
|
|
||||||
fields: ['views']
|
|
||||||
})
|
|
||||||
const currentViews = listing.data?.views || 0
|
|
||||||
const newViews = currentViews + 1
|
|
||||||
|
|
||||||
// Increment views
|
|
||||||
await this.patch(`/items/listings/${id}`, {
|
|
||||||
views: newViews
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mark as viewed for this session
|
|
||||||
sessionStorage.setItem(viewedKey, 'true')
|
|
||||||
return newViews
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to increment views:', error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteListing(id) {
|
|
||||||
return this.delete(`/items/listings/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMyListings() {
|
|
||||||
const response = await this.get('/items/listings', {
|
|
||||||
fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.name'],
|
|
||||||
filter: { user_created: { _eq: '$CURRENT_USER' } },
|
|
||||||
sort: ['-date_created']
|
|
||||||
})
|
|
||||||
return response.data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchListings(query, options = {}) {
|
|
||||||
return this.getListings({
|
|
||||||
search: query,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Categories (Kategorien) ====================
|
|
||||||
|
|
||||||
async getCategories() {
|
|
||||||
const response = await this.get('/items/categories', {
|
|
||||||
fields: ['*', 'translations.*'],
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCategoryTree(categories, parentId = null) {
|
|
||||||
return categories
|
|
||||||
.filter(cat => (cat.parent?.id || cat.parent) === parentId)
|
|
||||||
.map(cat => ({
|
|
||||||
...cat,
|
|
||||||
children: this.buildCategoryTree(categories, cat.id)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 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.id',
|
|
||||||
'listing_id.title',
|
|
||||||
'listing_id.status',
|
|
||||||
'listing_id.images.directus_files_id.id'
|
|
||||||
],
|
|
||||||
filter: {
|
|
||||||
_or: [
|
|
||||||
{ participant_hash_1: { _eq: participantHash } },
|
|
||||||
{ participant_hash_2: { _eq: participantHash } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
sort: ['-date_updated']
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConversation(id) {
|
|
||||||
const response = await this.get(`/items/conversations/${id}`, {
|
|
||||||
fields: [
|
|
||||||
'*',
|
|
||||||
'listing_id.*',
|
|
||||||
'listing_id.images.directus_files_id.*'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConversationMessages(conversationId) {
|
|
||||||
const response = await this.get('/items/messages', {
|
|
||||||
fields: ['*'],
|
|
||||||
filter: { conversation: { _eq: conversationId } },
|
|
||||||
sort: ['date_created']
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Files (Dateien/Bilder) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to Directus
|
|
||||||
* @param {File} file - File to upload
|
|
||||||
* @param {Object} [options={}] - Upload options
|
|
||||||
* @param {string} [options.folder] - Target folder ID
|
|
||||||
* @returns {Promise<Object>} Uploaded file data
|
|
||||||
*/
|
|
||||||
async uploadFile(file, options = {}) {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
if (options.folder) {
|
|
||||||
formData.append('folder', options.folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/files`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.accessToken ? { 'Authorization': `Bearer ${this.accessToken}` } : {},
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({}))
|
|
||||||
throw new DirectusError(response.status, 'Upload failed', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadMultipleFiles(files, options = {}) {
|
|
||||||
const uploads = Array.from(files).map(file => this.uploadFile(file, options))
|
|
||||||
return Promise.all(uploads)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get URL for a file with optional transformations
|
|
||||||
* @param {string} fileId - File UUID
|
|
||||||
* @param {Object} [options={}] - Transform options
|
|
||||||
* @param {number} [options.width] - Resize width
|
|
||||||
* @param {number} [options.height] - Resize height
|
|
||||||
* @param {string} [options.fit] - Fit mode (cover, contain, etc.)
|
|
||||||
* @returns {string|null} File URL or null
|
|
||||||
*/
|
|
||||||
getFileUrl(fileId, options = {}) {
|
|
||||||
if (!fileId) return null
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
|
|
||||||
if (options.width) params.set('width', options.width)
|
|
||||||
if (options.height) params.set('height', options.height)
|
|
||||||
if (options.fit) params.set('fit', options.fit)
|
|
||||||
if (options.quality) params.set('quality', options.quality)
|
|
||||||
if (options.format) params.set('format', options.format)
|
|
||||||
|
|
||||||
const queryString = params.toString()
|
|
||||||
return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notifications ──
|
// ── Notifications ──
|
||||||
|
getNotifications: notificationsApi.getNotifications,
|
||||||
async getNotifications(userHash, options = {}) {
|
getUnreadCount: notificationsApi.getUnreadCount,
|
||||||
const params = {
|
markNotificationRead: notificationsApi.markNotificationRead,
|
||||||
fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'],
|
markAllNotificationsRead: notificationsApi.markAllNotificationsRead,
|
||||||
filter: { user_hash: { _eq: userHash } },
|
deleteNotification: notificationsApi.deleteNotification
|
||||||
sort: ['-date_created'],
|
|
||||||
limit: options.limit || 50
|
|
||||||
}
|
|
||||||
if (options.unreadOnly) {
|
|
||||||
params.filter.read = { _eq: false }
|
|
||||||
}
|
|
||||||
const response = await this.get('/items/notifications', params)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUnreadCount(userHash) {
|
|
||||||
const response = await this.get('/items/notifications', {
|
|
||||||
filter: { user_hash: { _eq: userHash }, read: { _eq: false } },
|
|
||||||
aggregate: { count: 'id' }
|
|
||||||
})
|
|
||||||
return parseInt(response.data?.[0]?.count?.id || '0', 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
async markNotificationRead(id) {
|
|
||||||
return this.patch(`/items/notifications/${id}`, { read: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
async markAllNotificationsRead(userHash) {
|
|
||||||
const unread = await this.getNotifications(userHash, { unreadOnly: true })
|
|
||||||
const updates = unread.map(n => this.markNotificationRead(n.id))
|
|
||||||
return Promise.all(updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteNotification(id) {
|
|
||||||
return this.delete(`/items/notifications/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
getThumbnailUrl(fileId, size = 300) {
|
|
||||||
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 })
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class DirectusError extends Error {
|
export { directus, DirectusError }
|
||||||
constructor(status, message, data = {}) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'DirectusError'
|
|
||||||
this.status = status
|
|
||||||
this.data = data
|
|
||||||
}
|
|
||||||
|
|
||||||
isAuthError() {
|
|
||||||
return this.status === 401 || this.status === 403
|
|
||||||
}
|
|
||||||
|
|
||||||
isNotFound() {
|
|
||||||
return this.status === 404
|
|
||||||
}
|
|
||||||
|
|
||||||
isValidationError() {
|
|
||||||
return this.status === 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton Export
|
|
||||||
export const directus = new DirectusService()
|
|
||||||
export { DirectusError }
|
|
||||||
|
|||||||
50
js/services/directus/auth.js
Normal file
50
js/services/directus/auth.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
export async function login(email, password) {
|
||||||
|
const response = await client.post('/auth/login', { email, password })
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
client.saveTokens(
|
||||||
|
response.data.access_token,
|
||||||
|
response.data.refresh_token,
|
||||||
|
response.data.expires
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
if (client.refreshToken) {
|
||||||
|
try {
|
||||||
|
await client.post('/auth/logout', { refresh_token: client.refreshToken })
|
||||||
|
} catch (e) {
|
||||||
|
// Tokens will be cleared anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(email, password) {
|
||||||
|
return client.post('/users/register', { email, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPasswordReset(email) {
|
||||||
|
return client.post('/auth/password/request', { email })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(token, password) {
|
||||||
|
return client.post('/auth/password/reset', { token, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
const response = await client.get('/users/me', {
|
||||||
|
fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status', 'preferred_currency', 'preferred_locale']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCurrentUser(data) {
|
||||||
|
const response = await client.patch('/users/me', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
55
js/services/directus/categories.js
Normal file
55
js/services/directus/categories.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
export async function getCategories() {
|
||||||
|
const response = await client.get('/items/categories', {
|
||||||
|
fields: ['*', 'translations.*'],
|
||||||
|
filter: { status: { _eq: 'published' } },
|
||||||
|
sort: ['sort', 'name'],
|
||||||
|
limit: -1
|
||||||
|
})
|
||||||
|
return response.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function 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 client.get(`/items/categories/${idOrSlug}`, {
|
||||||
|
fields: ['*', 'translations.*', 'parent.*']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.get('/items/categories', {
|
||||||
|
fields: ['*', 'translations.*', 'parent.*'],
|
||||||
|
filter: { slug: { _eq: idOrSlug } },
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
return response.data?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoryTree() {
|
||||||
|
const categories = await getCategories()
|
||||||
|
return buildCategoryTree(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCategoryTree(categories, parentId = null) {
|
||||||
|
return categories
|
||||||
|
.filter(cat => (cat.parent?.id || cat.parent) === parentId)
|
||||||
|
.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
children: buildCategoryTree(categories, cat.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubcategories(parentId) {
|
||||||
|
const response = await client.get('/items/categories', {
|
||||||
|
fields: ['*', 'translations.*'],
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
parent: { _eq: parentId }
|
||||||
|
},
|
||||||
|
sort: ['sort', 'name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
255
js/services/directus/client.js
Normal file
255
js/services/directus/client.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
const DIRECTUS_URL = 'https://api.dgray.io'
|
||||||
|
|
||||||
|
class DirectusError extends Error {
|
||||||
|
constructor(status, message, data = {}) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'DirectusError'
|
||||||
|
this.status = status
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthError() {
|
||||||
|
return this.status === 401 || this.status === 403
|
||||||
|
}
|
||||||
|
|
||||||
|
isNotFound() {
|
||||||
|
return this.status === 404
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidationError() {
|
||||||
|
return this.status === 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DirectusClient {
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = DIRECTUS_URL
|
||||||
|
this.accessToken = null
|
||||||
|
this.refreshToken = null
|
||||||
|
this.tokenExpiry = null
|
||||||
|
this.refreshTimeout = null
|
||||||
|
this._refreshPromise = null
|
||||||
|
|
||||||
|
this.loadTokens()
|
||||||
|
this.setupVisibilityRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Management ──
|
||||||
|
|
||||||
|
loadTokens() {
|
||||||
|
const stored = localStorage.getItem('dgray_auth')
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const { accessToken, refreshToken, expiry } = JSON.parse(stored)
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.tokenExpiry = expiry
|
||||||
|
this.scheduleTokenRefresh()
|
||||||
|
} catch (e) {
|
||||||
|
this.clearTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTokens(accessToken, refreshToken, expiresIn) {
|
||||||
|
this.accessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.tokenExpiry = Date.now() + (expiresIn * 1000)
|
||||||
|
|
||||||
|
localStorage.setItem('dgray_auth', JSON.stringify({
|
||||||
|
accessToken: this.accessToken,
|
||||||
|
refreshToken: this.refreshToken,
|
||||||
|
expiry: this.tokenExpiry
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.scheduleTokenRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTokens() {
|
||||||
|
this.accessToken = null
|
||||||
|
this.refreshToken = null
|
||||||
|
this.tokenExpiry = null
|
||||||
|
localStorage.removeItem('dgray_auth')
|
||||||
|
|
||||||
|
if (this.refreshTimeout) {
|
||||||
|
clearTimeout(this.refreshTimeout)
|
||||||
|
this.refreshTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTokenRefresh() {
|
||||||
|
if (this.refreshTimeout) {
|
||||||
|
clearTimeout(this.refreshTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.tokenExpiry || !this.refreshToken) return
|
||||||
|
|
||||||
|
const refreshIn = this.tokenExpiry - Date.now() - 60000
|
||||||
|
|
||||||
|
if (refreshIn > 0) {
|
||||||
|
this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn)
|
||||||
|
} else if (this.refreshToken) {
|
||||||
|
this.refreshSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupVisibilityRefresh() {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && this.refreshToken) {
|
||||||
|
const timeLeft = this.tokenExpiry - Date.now()
|
||||||
|
if (timeLeft < 120000) {
|
||||||
|
this.refreshSession()
|
||||||
|
}
|
||||||
|
this.scheduleTokenRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated() {
|
||||||
|
return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(_retryCount = 0) {
|
||||||
|
if (!this.refreshToken) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: this.refreshToken,
|
||||||
|
mode: 'json'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Refresh failed')
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
this.saveTokens(
|
||||||
|
result.data.access_token,
|
||||||
|
result.data.refresh_token,
|
||||||
|
result.data.expires
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (_retryCount < 2) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
return this.refreshSession(_retryCount + 1)
|
||||||
|
}
|
||||||
|
this.clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP Methods ──
|
||||||
|
|
||||||
|
async request(endpoint, options = {}, _retryCount = 0) {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) {
|
||||||
|
if (!this._refreshPromise) {
|
||||||
|
this._refreshPromise = this.refreshSession().finally(() => {
|
||||||
|
this._refreshPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const refreshed = await this._refreshPromise
|
||||||
|
if (!refreshed) {
|
||||||
|
this.clearTokens()
|
||||||
|
}
|
||||||
|
return this.request(endpoint, options, _retryCount + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429 && _retryCount < 3) {
|
||||||
|
const retryAfterHeader = response.headers.get('Retry-After') || '1'
|
||||||
|
let waitMs
|
||||||
|
if (parseInt(retryAfterHeader, 10) > 100) {
|
||||||
|
waitMs = parseInt(retryAfterHeader, 10)
|
||||||
|
} else {
|
||||||
|
waitMs = parseInt(retryAfterHeader, 10) * 1000
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, waitMs))
|
||||||
|
return this.request(endpoint, options, _retryCount + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DirectusError) throw error
|
||||||
|
throw new DirectusError(0, 'Network error', { originalError: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(endpoint, params = {}) {
|
||||||
|
const queryString = this.buildQueryString(params)
|
||||||
|
const url = queryString ? `${endpoint}?${queryString}` : endpoint
|
||||||
|
return this.request(url, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(endpoint, data) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(endpoint, data) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(endpoint) {
|
||||||
|
return this.request(endpoint, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
buildQueryString(params) {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null) continue
|
||||||
|
|
||||||
|
if (key === 'sort' && Array.isArray(value)) {
|
||||||
|
searchParams.set(key, value.join(','))
|
||||||
|
} else if (key === 'fields' && Array.isArray(value)) {
|
||||||
|
searchParams.set(key, value.join(','))
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
searchParams.set(key, JSON.stringify(value))
|
||||||
|
} else {
|
||||||
|
searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchParams.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = new DirectusClient()
|
||||||
|
export { DirectusError }
|
||||||
93
js/services/directus/conversations.js
Normal file
93
js/services/directus/conversations.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
export async function getConversations(participantHash) {
|
||||||
|
const response = await client.get('/items/conversations', {
|
||||||
|
fields: [
|
||||||
|
'*',
|
||||||
|
'listing_id.id',
|
||||||
|
'listing_id.title',
|
||||||
|
'listing_id.status',
|
||||||
|
'listing_id.images.directus_files_id.id'
|
||||||
|
],
|
||||||
|
filter: {
|
||||||
|
_or: [
|
||||||
|
{ participant_hash_1: { _eq: participantHash } },
|
||||||
|
{ participant_hash_2: { _eq: participantHash } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sort: ['-date_updated']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversation(id) {
|
||||||
|
const response = await client.get(`/items/conversations/${id}`, {
|
||||||
|
fields: [
|
||||||
|
'*',
|
||||||
|
'listing_id.*',
|
||||||
|
'listing_id.images.directus_files_id.*'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationMessages(conversationId) {
|
||||||
|
const response = await client.get('/items/messages', {
|
||||||
|
fields: ['*'],
|
||||||
|
filter: { conversation: { _eq: conversationId } },
|
||||||
|
sort: ['date_created']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') {
|
||||||
|
const response = await client.post('/items/messages', {
|
||||||
|
conversation: conversationId,
|
||||||
|
sender_hash: senderHash,
|
||||||
|
content_encrypted: encryptedContent,
|
||||||
|
nonce: nonce,
|
||||||
|
type: type
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) {
|
||||||
|
const response = await client.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
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findConversation(listingId, participantHash1, participantHash2) {
|
||||||
|
const response = await client.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
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConversationStatus(id, status) {
|
||||||
|
const response = await client.patch(`/items/conversations/${id}`, { status })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
48
js/services/directus/files.js
Normal file
48
js/services/directus/files.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { client, DirectusError } from './client.js'
|
||||||
|
|
||||||
|
export async function uploadFile(file, options = {}) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
if (options.folder) {
|
||||||
|
formData.append('folder', options.folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${client.baseUrl}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: client.accessToken ? { 'Authorization': `Bearer ${client.accessToken}` } : {},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new DirectusError(response.status, 'Upload failed', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMultipleFiles(files, options = {}) {
|
||||||
|
const uploads = Array.from(files).map(file => uploadFile(file, options))
|
||||||
|
return Promise.all(uploads)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileUrl(fileId, options = {}) {
|
||||||
|
if (!fileId) return null
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
if (options.width) params.set('width', options.width)
|
||||||
|
if (options.height) params.set('height', options.height)
|
||||||
|
if (options.fit) params.set('fit', options.fit)
|
||||||
|
if (options.quality) params.set('quality', options.quality)
|
||||||
|
if (options.format) params.set('format', options.format)
|
||||||
|
|
||||||
|
const queryString = params.toString()
|
||||||
|
return `${client.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThumbnailUrl(fileId, size = 300) {
|
||||||
|
return getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 })
|
||||||
|
}
|
||||||
163
js/services/directus/listings.js
Normal file
163
js/services/directus/listings.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'price',
|
||||||
|
'currency',
|
||||||
|
'condition',
|
||||||
|
'expires_at',
|
||||||
|
'date_created',
|
||||||
|
'user_created',
|
||||||
|
'images.directus_files_id.id',
|
||||||
|
'category.id',
|
||||||
|
'category.name',
|
||||||
|
'category.slug',
|
||||||
|
'category.icon',
|
||||||
|
'location.id',
|
||||||
|
'location.name',
|
||||||
|
'location.postal_code',
|
||||||
|
'location.country',
|
||||||
|
'location.latitude',
|
||||||
|
'location.longitude'
|
||||||
|
]
|
||||||
|
|
||||||
|
const DETAIL_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'price',
|
||||||
|
'currency',
|
||||||
|
'price_mode',
|
||||||
|
'price_type',
|
||||||
|
'condition',
|
||||||
|
'shipping',
|
||||||
|
'shipping_cost',
|
||||||
|
'views',
|
||||||
|
'paid_at',
|
||||||
|
'payment_status',
|
||||||
|
'expires_at',
|
||||||
|
'date_created',
|
||||||
|
'user_created',
|
||||||
|
'images.directus_files_id.id',
|
||||||
|
'category.id',
|
||||||
|
'category.name',
|
||||||
|
'category.slug',
|
||||||
|
'category.translations.*',
|
||||||
|
'location.id',
|
||||||
|
'location.name',
|
||||||
|
'location.postal_code',
|
||||||
|
'location.country'
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function getListings(options = {}) {
|
||||||
|
const params = {
|
||||||
|
fields: options.fields || DEFAULT_FIELDS,
|
||||||
|
filter: options.filter || {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
_or: [
|
||||||
|
{ expires_at: { _null: true } },
|
||||||
|
{ expires_at: { _gt: '$NOW' } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sort: options.sort || ['-date_created'],
|
||||||
|
limit: options.limit || 20,
|
||||||
|
page: options.page || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.search) {
|
||||||
|
params.search = options.search
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.get('/items/listings', params)
|
||||||
|
return {
|
||||||
|
items: response.data,
|
||||||
|
meta: response.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListing(id) {
|
||||||
|
const response = await client.get(`/items/listings/${id}`, {
|
||||||
|
fields: DETAIL_FIELDS
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createListing(data) {
|
||||||
|
const response = await client.post('/items/listings', data)
|
||||||
|
return response?.data || response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateListing(id, data) {
|
||||||
|
const response = await client.patch(`/items/listings/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementListingViews(id) {
|
||||||
|
const viewedKey = `dgray_viewed_${id}`
|
||||||
|
if (sessionStorage.getItem(viewedKey)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const listing = await client.get(`/items/listings/${id}`, {
|
||||||
|
fields: ['views']
|
||||||
|
})
|
||||||
|
const currentViews = listing.data?.views || 0
|
||||||
|
const newViews = currentViews + 1
|
||||||
|
|
||||||
|
await client.patch(`/items/listings/${id}`, {
|
||||||
|
views: newViews
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionStorage.setItem(viewedKey, 'true')
|
||||||
|
return newViews
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to increment views:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteListing(id) {
|
||||||
|
return client.delete(`/items/listings/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyListings() {
|
||||||
|
const response = await client.get('/items/listings', {
|
||||||
|
fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.name'],
|
||||||
|
filter: { user_created: { _eq: '$CURRENT_USER' } },
|
||||||
|
sort: ['-date_created']
|
||||||
|
})
|
||||||
|
return response.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchListings(query, options = {}) {
|
||||||
|
return getListings({
|
||||||
|
search: query,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListingsByCategory(categoryId, options = {}) {
|
||||||
|
return getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
category: { _eq: categoryId }
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListingsByLocation(locationId, options = {}) {
|
||||||
|
return getListings({
|
||||||
|
filter: {
|
||||||
|
status: { _eq: 'published' },
|
||||||
|
location: { _eq: locationId }
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
40
js/services/directus/locations.js
Normal file
40
js/services/directus/locations.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
export async function getLocations(options = {}) {
|
||||||
|
const response = await client.get('/items/locations', {
|
||||||
|
fields: options.fields || ['*'],
|
||||||
|
filter: options.filter || {},
|
||||||
|
sort: options.sort || ['name'],
|
||||||
|
limit: options.limit || -1
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocation(id) {
|
||||||
|
const response = await client.get(`/items/locations/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchLocations(query) {
|
||||||
|
const response = await client.get('/items/locations', {
|
||||||
|
search: query,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocationsByRegion(region) {
|
||||||
|
const response = await client.get('/items/locations', {
|
||||||
|
filter: { region: { _eq: region } },
|
||||||
|
sort: ['name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocationsByCountry(country) {
|
||||||
|
const response = await client.get('/items/locations', {
|
||||||
|
filter: { country: { _eq: country } },
|
||||||
|
sort: ['region', 'name']
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
37
js/services/directus/notifications.js
Normal file
37
js/services/directus/notifications.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { client } from './client.js'
|
||||||
|
|
||||||
|
export async function getNotifications(userHash, options = {}) {
|
||||||
|
const params = {
|
||||||
|
fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'],
|
||||||
|
filter: { user_hash: { _eq: userHash } },
|
||||||
|
sort: ['-date_created'],
|
||||||
|
limit: options.limit || 50
|
||||||
|
}
|
||||||
|
if (options.unreadOnly) {
|
||||||
|
params.filter.read = { _eq: false }
|
||||||
|
}
|
||||||
|
const response = await client.get('/items/notifications', params)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(userHash) {
|
||||||
|
const response = await client.get('/items/notifications', {
|
||||||
|
filter: { user_hash: { _eq: userHash }, read: { _eq: false } },
|
||||||
|
aggregate: { count: 'id' }
|
||||||
|
})
|
||||||
|
return parseInt(response.data?.[0]?.count?.id || '0', 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id) {
|
||||||
|
return client.patch(`/items/notifications/${id}`, { read: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(userHash) {
|
||||||
|
const unread = await getNotifications(userHash, { unreadOnly: true })
|
||||||
|
const updates = unread.map(n => markNotificationRead(n.id))
|
||||||
|
return Promise.all(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotification(id) {
|
||||||
|
return client.delete(`/items/notifications/${id}`)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'dgray-v50';
|
const CACHE_NAME = 'dgray-v51';
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -18,6 +18,14 @@ const STATIC_ASSETS = [
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
'/js/services/directus.js',
|
'/js/services/directus.js',
|
||||||
|
'/js/services/directus/client.js',
|
||||||
|
'/js/services/directus/auth.js',
|
||||||
|
'/js/services/directus/listings.js',
|
||||||
|
'/js/services/directus/categories.js',
|
||||||
|
'/js/services/directus/locations.js',
|
||||||
|
'/js/services/directus/conversations.js',
|
||||||
|
'/js/services/directus/files.js',
|
||||||
|
'/js/services/directus/notifications.js',
|
||||||
'/js/services/auth.js',
|
'/js/services/auth.js',
|
||||||
'/js/services/listings.js',
|
'/js/services/listings.js',
|
||||||
'/js/services/categories.js',
|
'/js/services/categories.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user