feat: implement seller-join flow for E2E chat with pending conversation discovery
This commit is contained in:
@@ -10,7 +10,7 @@ import { escapeHTML } from '../utils/helpers.js'
|
||||
|
||||
class ChatWidget extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['listing-id', 'seller-public-key', 'recipient-name']
|
||||
return ['listing-id', 'recipient-name']
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -20,27 +20,13 @@ class ChatWidget extends HTMLElement {
|
||||
this.unsubscribe = null
|
||||
this.loading = true
|
||||
this.error = null
|
||||
this._initialized = false
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await cryptoService.ready
|
||||
|
||||
connectedCallback() {
|
||||
this.listingId = this.getAttribute('listing-id')
|
||||
this.sellerPublicKey = this.getAttribute('seller-public-key')
|
||||
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
|
||||
|
||||
this.render()
|
||||
|
||||
if (this.listingId && this.sellerPublicKey) {
|
||||
await this.initConversation()
|
||||
} else {
|
||||
this.loading = false
|
||||
this.error = 'missing-data'
|
||||
this.render()
|
||||
}
|
||||
|
||||
this.unsubscribe = conversationsService.subscribe(() => this.refreshMessages())
|
||||
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -48,12 +34,34 @@ class ChatWidget extends HTMLElement {
|
||||
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
|
||||
}
|
||||
|
||||
async activate() {
|
||||
if (this._initialized) return
|
||||
this._initialized = true
|
||||
|
||||
await cryptoService.ready
|
||||
|
||||
if (!cryptoService.getPublicKey()) {
|
||||
this.loading = false
|
||||
this.error = 'no-keypair'
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.listingId) {
|
||||
await this.initConversation()
|
||||
} else {
|
||||
this.loading = false
|
||||
this.error = 'missing-data'
|
||||
this.render()
|
||||
}
|
||||
|
||||
this.unsubscribe = conversationsService.subscribe(() => this.refreshMessages())
|
||||
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
|
||||
}
|
||||
|
||||
async initConversation() {
|
||||
try {
|
||||
this.conversation = await conversationsService.startOrGetConversation(
|
||||
this.listingId,
|
||||
this.sellerPublicKey
|
||||
)
|
||||
this.conversation = await conversationsService.startOrFindByListing(this.listingId)
|
||||
await this.loadMessages()
|
||||
} catch (e) {
|
||||
console.error('Failed to init conversation:', e)
|
||||
@@ -101,6 +109,8 @@ class ChatWidget extends HTMLElement {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = this.conversation && !this.conversation.otherPublicKey
|
||||
|
||||
this.innerHTML = /* html */`
|
||||
<div class="chat-widget">
|
||||
<div class="chat-header">
|
||||
@@ -109,17 +119,20 @@ class ChatWidget extends HTMLElement {
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
${this.renderMessagesHtml()}
|
||||
${pending
|
||||
? /* html */`<div class="chat-empty"><p>${t('chat.pending')}</p></div>`
|
||||
: this.renderMessagesHtml()}
|
||||
</div>
|
||||
|
||||
<form class="chat-input" id="chat-form">
|
||||
<input
|
||||
type="text"
|
||||
id="message-input"
|
||||
placeholder="${t('chat.placeholder')}"
|
||||
placeholder="${pending ? t('chat.pendingHint') : t('chat.placeholder')}"
|
||||
autocomplete="off"
|
||||
${pending ? 'disabled' : ''}
|
||||
>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" ${pending ? 'disabled' : ''}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t, i18n } from '../../i18n.js'
|
||||
import { directus } from '../../services/directus.js'
|
||||
import { auth } from '../../services/auth.js'
|
||||
import { conversationsService } from '../../services/conversations.js'
|
||||
import { favoritesService } from '../../services/favorites.js'
|
||||
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
|
||||
import { escapeHTML } from '../../utils/helpers.js'
|
||||
@@ -18,6 +19,7 @@ class PageListing extends HTMLElement {
|
||||
this.isFavorite = false
|
||||
this.rates = null
|
||||
this.isOwner = false
|
||||
this.hasPendingChats = false
|
||||
this.handleCurrencyChange = this.handleCurrencyChange.bind(this)
|
||||
}
|
||||
|
||||
@@ -58,6 +60,11 @@ class PageListing extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for pending chats if owner
|
||||
if (this.isOwner) {
|
||||
this.hasPendingChats = await this.checkPendingChats()
|
||||
}
|
||||
|
||||
// Load other listings from same seller
|
||||
if (this.listing?.user_created) {
|
||||
await this.loadSellerListings()
|
||||
@@ -138,6 +145,23 @@ class PageListing extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
async checkPendingChats() {
|
||||
try {
|
||||
const response = await directus.get('/items/conversations', {
|
||||
fields: ['id'],
|
||||
filter: {
|
||||
listing_id: { _eq: this.listingId },
|
||||
status: { _eq: 'pending' },
|
||||
participant_hash_2: { _null: true }
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
return (response.data?.length || 0) > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async loadSellerListings() {
|
||||
try {
|
||||
const response = await directus.getListings({
|
||||
@@ -348,6 +372,15 @@ class PageListing extends HTMLElement {
|
||||
</svg>
|
||||
${t('listing.edit')}
|
||||
</a>
|
||||
|
||||
${this.hasPendingChats ? /* html */`
|
||||
<button class="btn btn-outline btn-lg sidebar-btn" id="contact-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
${t('listing.newMessages')}
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
<div class="sidebar-actions">
|
||||
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
|
||||
@@ -450,7 +483,6 @@ class PageListing extends HTMLElement {
|
||||
<div class="tab-content" id="tab-chat">
|
||||
<chat-widget
|
||||
listing-id="${this.listing?.id || ''}"
|
||||
seller-public-key="${this.listing?.seller_public_key || ''}"
|
||||
recipient-name="${t('listing.anonymousSeller')}"
|
||||
></chat-widget>
|
||||
</div>
|
||||
@@ -501,11 +533,19 @@ class PageListing extends HTMLElement {
|
||||
|
||||
setupEventListeners() {
|
||||
// Contact dialog
|
||||
const contactBtn = this.querySelector('#contact-btn')
|
||||
const dialog = this.querySelector('#contact-dialog')
|
||||
const closeBtn = this.querySelector('#dialog-close')
|
||||
|
||||
contactBtn?.addEventListener('click', () => dialog?.showModal())
|
||||
|
||||
this.querySelectorAll('#contact-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!auth.isLoggedIn()) {
|
||||
document.querySelector('auth-modal')?.show()
|
||||
return
|
||||
}
|
||||
dialog?.showModal()
|
||||
this.querySelector('chat-widget')?.activate()
|
||||
})
|
||||
})
|
||||
closeBtn?.addEventListener('click', () => dialog?.close())
|
||||
dialog?.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) dialog.close()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { t, i18n } from '../../i18n.js'
|
||||
import { auth } from '../../services/auth.js'
|
||||
import { directus } from '../../services/directus.js'
|
||||
import { conversationsService } from '../../services/conversations.js'
|
||||
import { escapeHTML } from '../../utils/helpers.js'
|
||||
|
||||
class PageMessages extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.conversations = []
|
||||
this.pendingConversations = []
|
||||
this.loading = true
|
||||
this.error = null
|
||||
this.isLoggedIn = false
|
||||
@@ -49,7 +51,14 @@ class PageMessages extends HTMLElement {
|
||||
}
|
||||
|
||||
const userHash = await auth.hashString(user.id)
|
||||
this.conversations = await directus.getConversations(userHash) || []
|
||||
const [conversations, pending] = await Promise.all([
|
||||
directus.getConversations(userHash),
|
||||
conversationsService.getPendingForMyListings().catch(() => [])
|
||||
])
|
||||
this.conversations = conversations || []
|
||||
this.pendingConversations = pending.filter(
|
||||
p => !this.conversations.some(c => c.id === p.id)
|
||||
)
|
||||
this.loading = false
|
||||
this.updateContent()
|
||||
} catch (err) {
|
||||
@@ -119,7 +128,7 @@ class PageMessages extends HTMLElement {
|
||||
`
|
||||
}
|
||||
|
||||
if (this.conversations.length === 0) {
|
||||
if (this.conversations.length === 0 && this.pendingConversations.length === 0) {
|
||||
return /* html */`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💬</div>
|
||||
@@ -130,28 +139,47 @@ class PageMessages extends HTMLElement {
|
||||
`
|
||||
}
|
||||
|
||||
return this.conversations.map(conv => {
|
||||
const listing = conv.listing_id
|
||||
const imageId = listing?.images?.[0]?.directus_files_id?.id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 80) : ''
|
||||
const title = listing?.status === 'deleted' ? t('messages.listingRemoved') : (listing?.title || t('messages.unknownListing'))
|
||||
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
|
||||
let html = ''
|
||||
|
||||
return /* html */`
|
||||
<a href="#/listing/${listing?.id}" class="conversation-item">
|
||||
<div class="conversation-image">
|
||||
${imageUrl
|
||||
? `<img src="${imageUrl}" alt="" loading="lazy">`
|
||||
: `<div class="image-placeholder">📦</div>`}
|
||||
</div>
|
||||
<div class="conversation-info">
|
||||
<h3 class="conversation-title">${escapeHTML(title)}</h3>
|
||||
<p class="conversation-date">${dateStr}</p>
|
||||
</div>
|
||||
<div class="conversation-arrow">→</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
if (this.pendingConversations.length > 0) {
|
||||
html += /* html */`<h2 class="section-title">${t('messages.pendingRequests')}</h2>`
|
||||
html += this.pendingConversations.map(conv => this.renderConversationItem(conv, true)).join('')
|
||||
}
|
||||
|
||||
if (this.conversations.length > 0) {
|
||||
if (this.pendingConversations.length > 0) {
|
||||
html += /* html */`<h2 class="section-title">${t('messages.activeChats')}</h2>`
|
||||
}
|
||||
html += this.conversations.map(conv => this.renderConversationItem(conv, false)).join('')
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
renderConversationItem(conv, isPending) {
|
||||
const listing = typeof conv.listing_id === 'object' ? conv.listing_id : null
|
||||
const listingId = listing?.id || conv.listing_id
|
||||
const imageId = listing?.images?.[0]?.directus_files_id?.id
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 80) : ''
|
||||
const title = listing?.status === 'deleted' ? t('messages.listingRemoved') : (listing?.title || t('messages.unknownListing'))
|
||||
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
|
||||
|
||||
return /* html */`
|
||||
<a href="#/listing/${listingId}" class="conversation-item ${isPending ? 'conversation-pending' : ''}" data-conv-id="${conv.id}">
|
||||
<div class="conversation-image">
|
||||
${imageUrl
|
||||
? `<img src="${imageUrl}" alt="" loading="lazy">`
|
||||
: `<div class="image-placeholder">📦</div>`}
|
||||
</div>
|
||||
<div class="conversation-info">
|
||||
<h3 class="conversation-title">${escapeHTML(title)}</h3>
|
||||
<p class="conversation-date">${dateStr}</p>
|
||||
</div>
|
||||
${isPending
|
||||
? `<span class="conversation-badge">${t('messages.new')}</span>`
|
||||
: `<div class="conversation-arrow">→</div>`}
|
||||
</a>
|
||||
`
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
@@ -298,5 +326,32 @@ style.textContent = /* css */`
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
page-messages .section-title {
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin: var(--space-lg) 0 var(--space-sm);
|
||||
}
|
||||
|
||||
page-messages .section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
page-messages .conversation-pending {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
page-messages .conversation-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
Reference in New Issue
Block a user