/** * 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', 'seller-public-key'] } 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 this.mySecretKey = null } connectedCallback() { this.listingId = this.getAttribute('listing-id') this.recipientName = this.getAttribute('recipient-name') || 'Seller' this.sellerPublicKey = this.getAttribute('seller-public-key') 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 { if (!this.sellerPublicKey) { this.error = 'no-seller-key' this.loading = false this.render() return } this.conversation = await conversationsService.startOrGetConversation(this.listingId, this.sellerPublicKey) this.mySecretKey = await conversationsService.getMySecretKeyForConversation(this.conversation) 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, this.mySecretKey ) } 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 */`
${t('common.loading')}
` return } if (this.error) { this.innerHTML = /* html */`

${t('chat.unavailable')}

` return } this.innerHTML = /* html */`
${escapeHTML(this.recipientName)} ๐Ÿ”’
${this.renderMessagesHtml()}
${this.renderDealSection()}
` this.setupEventListeners() this.scrollToBottom() } renderMessagesHtml() { if (this.messages.length === 0) { return /* html */`

${t('chat.startConversation')}

` } return this.messages.map(msg => /* html */`

${escapeHTML(msg.text)}

${this.formatTime(msg.timestamp)}
`).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 */`
` } 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 */`
${t('reputation.dealPending')}
` } return /* html */`
${t('reputation.confirmDealHint')}
` } if (this.deal.status === 'confirmed') { if (this.hasRated) { return /* html */`
โœ“ ${t('reputation.dealConfirmed')} ยท ${t('reputation.rated')}
` } return /* html */`
โœ“ ${t('reputation.dealConfirmed')}
${t('reputation.rate')}
${[1,2,3,4,5].map(s => ``).join('')}
` } 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, 'text', this.mySecretKey ) 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 }