614 lines
18 KiB
JavaScript
614 lines
18 KiB
JavaScript
/**
|
|
* Chat Widget Component
|
|
* Embedded chat for buyer-seller communication using Directus conversations
|
|
*/
|
|
|
|
import { t, i18n } from '../i18n.js'
|
|
import { conversationsService } from '../services/conversations.js'
|
|
import { cryptoService } from '../services/crypto.js'
|
|
import { escapeHTML } from '../utils/helpers.js'
|
|
import { reputationService } from '../services/reputation.js'
|
|
|
|
class ChatWidget extends HTMLElement {
|
|
static get observedAttributes() {
|
|
return ['listing-id', 'recipient-name']
|
|
}
|
|
|
|
constructor() {
|
|
super()
|
|
this.conversation = null
|
|
this.messages = []
|
|
this.unsubscribe = null
|
|
this.loading = true
|
|
this.error = null
|
|
this._initialized = false
|
|
this.deal = null
|
|
this.hasRated = false
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.listingId = this.getAttribute('listing-id')
|
|
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
|
|
this.render()
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.unsubscribe) this.unsubscribe()
|
|
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.startOrFindByListing(this.listingId)
|
|
await this.loadMessages()
|
|
await this.loadDealState()
|
|
} catch (e) {
|
|
console.error('Failed to init conversation:', e)
|
|
this.error = 'init-failed'
|
|
}
|
|
this.loading = false
|
|
this.render()
|
|
this.setupEventListeners()
|
|
}
|
|
|
|
async loadMessages() {
|
|
if (!this.conversation) return
|
|
this.messages = await conversationsService.getMessages(
|
|
this.conversation.id,
|
|
this.conversation.otherPublicKey
|
|
)
|
|
}
|
|
|
|
async refreshMessages() {
|
|
await this.loadMessages()
|
|
this.renderMessages()
|
|
this.scrollToBottom()
|
|
}
|
|
|
|
async loadDealState() {
|
|
if (!this.conversation) return
|
|
try {
|
|
const deals = await reputationService.getDealsForConversation(this.conversation.id)
|
|
this.deal = deals[0] || null
|
|
this._cachedUserHash = await reputationService.getUserHash()
|
|
if (this.deal && this.deal.status === 'confirmed') {
|
|
const userHash = this._cachedUserHash
|
|
const ratings = await reputationService.getRatingsForUser(
|
|
this.deal.seller_hash === userHash ? this.deal.buyer_hash : this.deal.seller_hash
|
|
)
|
|
this.hasRated = ratings.some(r => r.deal === this.deal.id && r.rater_hash === userHash)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load deal state:', e)
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (this.loading) {
|
|
this.innerHTML = /* html */`
|
|
<div class="chat-widget">
|
|
<div class="chat-loading">
|
|
<span>${t('common.loading')}</span>
|
|
</div>
|
|
</div>
|
|
`
|
|
return
|
|
}
|
|
|
|
if (this.error) {
|
|
this.innerHTML = /* html */`
|
|
<div class="chat-widget">
|
|
<div class="chat-error">
|
|
<p>${t('chat.unavailable')}</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
return
|
|
}
|
|
|
|
const pending = this.conversation && !this.conversation.otherPublicKey
|
|
|
|
this.innerHTML = /* html */`
|
|
<div class="chat-widget">
|
|
<div class="chat-header">
|
|
<span class="chat-recipient">${escapeHTML(this.recipientName)}</span>
|
|
<span class="chat-encrypted" title="${t('chat.encrypted')}">🔒</span>
|
|
</div>
|
|
|
|
<div class="chat-messages" id="chat-messages">
|
|
${pending
|
|
? /* html */`<div class="chat-empty"><p>${t('chat.pending')}</p></div>`
|
|
: this.renderMessagesHtml()}
|
|
</div>
|
|
|
|
${this.renderDealSection()}
|
|
|
|
<form class="chat-input" id="chat-form">
|
|
<input
|
|
type="text"
|
|
id="message-input"
|
|
placeholder="${pending ? t('chat.pendingHint') : t('chat.placeholder')}"
|
|
autocomplete="off"
|
|
${pending ? 'disabled' : ''}
|
|
>
|
|
<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>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
`
|
|
|
|
this.setupEventListeners()
|
|
this.scrollToBottom()
|
|
}
|
|
|
|
renderMessagesHtml() {
|
|
if (this.messages.length === 0) {
|
|
return /* html */`
|
|
<div class="chat-empty">
|
|
<p>${t('chat.startConversation')}</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
return this.messages.map(msg => /* html */`
|
|
<div class="chat-message ${msg.isOwn ? 'own' : 'other'}">
|
|
<div class="message-bubble">
|
|
<p>${escapeHTML(msg.text)}</p>
|
|
<span class="message-time">${this.formatTime(msg.timestamp)}</span>
|
|
</div>
|
|
</div>
|
|
`).join('')
|
|
}
|
|
|
|
renderMessages() {
|
|
const container = this.querySelector('#chat-messages')
|
|
if (container) {
|
|
container.innerHTML = this.renderMessagesHtml()
|
|
}
|
|
}
|
|
|
|
renderDealSection() {
|
|
if (!this.conversation || !this.conversation.otherPublicKey) return ''
|
|
|
|
if (!this.deal) {
|
|
return /* html */`
|
|
<div class="deal-section">
|
|
<button class="deal-btn" id="deal-create-btn">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
|
<circle cx="9" cy="7" r="4"></circle>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
|
</svg>
|
|
${t('reputation.confirmDeal')}
|
|
</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
if (this.deal.status === 'pending') {
|
|
const userHash = this._cachedUserHash
|
|
const iAmConfirmed = (this.deal.seller_hash === userHash && this.deal.seller_confirmed)
|
|
|| (this.deal.buyer_hash === userHash && this.deal.buyer_confirmed)
|
|
|
|
if (iAmConfirmed) {
|
|
return /* html */`
|
|
<div class="deal-section">
|
|
<div class="deal-status deal-pending">
|
|
<span class="pulse-dot"></span>
|
|
${t('reputation.dealPending')}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
return /* html */`
|
|
<div class="deal-section">
|
|
<button class="deal-btn deal-confirm" id="deal-confirm-btn">
|
|
${t('reputation.confirmDeal')}
|
|
</button>
|
|
<span class="deal-hint">${t('reputation.confirmDealHint')}</span>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
if (this.deal.status === 'confirmed') {
|
|
if (this.hasRated) {
|
|
return /* html */`
|
|
<div class="deal-section">
|
|
<div class="deal-status deal-confirmed">
|
|
✓ ${t('reputation.dealConfirmed')} · ${t('reputation.rated')}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
return /* html */`
|
|
<div class="deal-section">
|
|
<div class="deal-status deal-confirmed">✓ ${t('reputation.dealConfirmed')}</div>
|
|
<div class="deal-rating" id="deal-rating">
|
|
<span class="rating-label">${t('reputation.rate')}</span>
|
|
<div class="rating-stars">
|
|
${[1,2,3,4,5].map(s => `<button class="star-btn" data-score="${s}" aria-label="${s}">★</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const form = this.querySelector('#chat-form')
|
|
form?.addEventListener('submit', (e) => this.handleSubmit(e))
|
|
|
|
const dealCreateBtn = this.querySelector('#deal-create-btn')
|
|
dealCreateBtn?.addEventListener('click', () => this.handleCreateDeal())
|
|
|
|
const dealConfirmBtn = this.querySelector('#deal-confirm-btn')
|
|
dealConfirmBtn?.addEventListener('click', () => this.handleConfirmDeal())
|
|
|
|
this.querySelectorAll('.star-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => this.handleRate(parseInt(btn.dataset.score)))
|
|
})
|
|
}
|
|
|
|
async handleSubmit(e) {
|
|
e.preventDefault()
|
|
|
|
const input = this.querySelector('#message-input')
|
|
const text = input?.value.trim()
|
|
|
|
if (!text || !this.conversation) return
|
|
|
|
input.value = ''
|
|
|
|
await conversationsService.sendMessage(
|
|
this.conversation.id,
|
|
this.conversation.otherPublicKey,
|
|
text
|
|
)
|
|
|
|
await this.refreshMessages()
|
|
}
|
|
|
|
async handleCreateDeal() {
|
|
if (!this.conversation) return
|
|
try {
|
|
this._cachedUserHash = await reputationService.getUserHash()
|
|
this.deal = await reputationService.createDeal(this.conversation)
|
|
this.render()
|
|
} catch (e) {
|
|
console.error('Failed to create deal:', e)
|
|
}
|
|
}
|
|
|
|
async handleConfirmDeal() {
|
|
if (!this.deal) return
|
|
try {
|
|
this.deal = await reputationService.confirmDeal(this.deal.id)
|
|
this.render()
|
|
} catch (e) {
|
|
console.error('Failed to confirm deal:', e)
|
|
}
|
|
}
|
|
|
|
async handleRate(score) {
|
|
if (!this.deal) return
|
|
try {
|
|
await reputationService.rateDeal(this.deal.id, score)
|
|
this.hasRated = true
|
|
this.render()
|
|
} catch (e) {
|
|
console.error('Failed to rate:', e)
|
|
}
|
|
}
|
|
|
|
scrollToBottom() {
|
|
const container = this.querySelector('#chat-messages')
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight
|
|
}
|
|
}
|
|
|
|
formatTime(timestamp) {
|
|
const date = new Date(timestamp)
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
}
|
|
|
|
customElements.define('chat-widget', ChatWidget)
|
|
|
|
const style = document.createElement('style')
|
|
style.textContent = /* css */`
|
|
chat-widget {
|
|
display: block;
|
|
}
|
|
|
|
chat-widget .chat-widget {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 400px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
chat-widget .chat-loading,
|
|
chat-widget .chat-error {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--color-text-muted);
|
|
text-align: center;
|
|
padding: var(--space-lg);
|
|
}
|
|
|
|
chat-widget .chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: var(--space-md);
|
|
background: var(--color-bg-secondary);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
chat-widget .chat-recipient {
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
chat-widget .chat-encrypted {
|
|
font-size: var(--font-size-sm);
|
|
cursor: help;
|
|
filter: grayscale(1);
|
|
}
|
|
|
|
chat-widget .chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: var(--space-md);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
chat-widget .chat-empty {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--color-text-muted);
|
|
text-align: center;
|
|
}
|
|
|
|
chat-widget .chat-message {
|
|
display: flex;
|
|
max-width: 80%;
|
|
}
|
|
|
|
chat-widget .chat-message.own {
|
|
align-self: flex-end;
|
|
}
|
|
|
|
chat-widget .chat-message.other {
|
|
align-self: flex-start;
|
|
}
|
|
|
|
chat-widget .message-bubble {
|
|
padding: var(--space-sm) var(--space-md);
|
|
border-radius: var(--radius-lg);
|
|
position: relative;
|
|
}
|
|
|
|
chat-widget .chat-message.own .message-bubble {
|
|
background: var(--color-text);
|
|
color: var(--color-bg);
|
|
border-bottom-right-radius: var(--radius-sm);
|
|
}
|
|
|
|
chat-widget .chat-message.other .message-bubble {
|
|
background: var(--color-bg-tertiary);
|
|
color: var(--color-text);
|
|
border-bottom-left-radius: var(--radius-sm);
|
|
}
|
|
|
|
chat-widget .message-bubble p {
|
|
margin: 0;
|
|
word-break: break-word;
|
|
}
|
|
|
|
chat-widget .message-time {
|
|
display: block;
|
|
font-size: var(--font-size-xs);
|
|
opacity: 0.7;
|
|
margin-top: var(--space-xs);
|
|
}
|
|
|
|
chat-widget .chat-input {
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
padding: var(--space-md);
|
|
border-top: 1px solid var(--color-border);
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
|
|
chat-widget .chat-input input {
|
|
flex: 1;
|
|
padding: var(--space-sm) var(--space-md);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: var(--font-size-base);
|
|
}
|
|
|
|
chat-widget .chat-input input::placeholder {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .chat-input input:focus {
|
|
outline: none;
|
|
border-color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .chat-input button {
|
|
padding: var(--space-sm);
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-text);
|
|
color: var(--color-bg);
|
|
}
|
|
|
|
chat-widget .chat-input button:hover {
|
|
background: var(--color-text-secondary);
|
|
}
|
|
|
|
chat-widget .deal-section {
|
|
padding: var(--space-sm) var(--space-md);
|
|
border-top: 1px solid var(--color-border);
|
|
background: var(--color-bg-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
chat-widget .deal-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-xs);
|
|
padding: var(--space-xs) var(--space-sm);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-bg);
|
|
color: var(--color-text-secondary);
|
|
font-size: var(--font-size-sm);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
chat-widget .deal-btn:hover {
|
|
border-color: var(--color-text-muted);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
chat-widget .deal-btn.deal-confirm {
|
|
background: var(--color-text);
|
|
color: var(--color-bg);
|
|
border-color: var(--color-text);
|
|
}
|
|
|
|
chat-widget .deal-btn.deal-confirm:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
chat-widget .deal-hint {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .deal-status {
|
|
font-size: var(--font-size-sm);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-xs);
|
|
}
|
|
|
|
chat-widget .deal-pending {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .deal-confirmed {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
chat-widget .deal-rating {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
margin-left: auto;
|
|
}
|
|
|
|
chat-widget .rating-label {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .rating-stars {
|
|
display: flex;
|
|
gap: 2px;
|
|
}
|
|
|
|
chat-widget .star-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: var(--font-size-lg);
|
|
color: var(--color-border);
|
|
padding: 0;
|
|
line-height: 1;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
chat-widget .star-btn:hover,
|
|
chat-widget .star-btn:hover ~ .star-btn {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
chat-widget .rating-stars:hover .star-btn {
|
|
color: var(--color-text);
|
|
}
|
|
|
|
chat-widget .rating-stars .star-btn:hover ~ .star-btn {
|
|
color: var(--color-border);
|
|
}
|
|
|
|
chat-widget .pulse-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-text-muted);
|
|
display: inline-block;
|
|
animation: pulse-deal 1.5s infinite;
|
|
}
|
|
|
|
@keyframes pulse-deal {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.3; }
|
|
}
|
|
`
|
|
document.head.appendChild(style)
|
|
|
|
export { ChatWidget }
|