Appearance
Chat Frontend — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Real-time 1-to-1 buyer-seller chat with SharedWorker WS singleton, optimistic updates, delivery/read receipts, presence, and system messages.
Architecture: Centrifugo v5.4.9 + Connect Proxy (cookie auth) + SharedWorker singleton + FSD layers. Single personal channel personal:#userId for all events. REST for mutations, WS for realtime delivery.
Tech Stack: centrifuge-js v5, Nuxt 4.3, Vue 3.5, Pinia, Nuxt UI v3, Zod, SharedWorker with BroadcastChannel fallback.
Backend guide: docs/plans/2026-02-12-chat-frontend-guide.md (all 5 improvements implemented).
Зависимость
bash
npm install centrifugeФайлы для создания (полный список)
app/
├── entities/
│ ├── conversation/
│ │ ├── model/conversation.schema.ts
│ │ └── ui/ConversationCard.vue
│ └── message/
│ ├── model/message.schema.ts
│ └── ui/
│ ├── MessageBubble.vue
│ └── MessageStatus.vue
├── features/
│ ├── chat-connection/
│ │ ├── composables/useCentrifugo.ts
│ │ └── worker/chat.shared-worker.ts
│ ├── chat-messaging/
│ │ ├── composables/useConversations.ts
│ │ ├── composables/useMessages.ts
│ │ ├── composables/useTyping.ts
│ │ ├── composables/useOnlineStatus.ts
│ │ └── composables/useChatNotifications.ts
│ ├── chat-image/
│ │ └── composables/useChatImageUpload.ts
│ └── contact-seller/
│ └── ui/ContactSellerButton.vue
├── widgets/
│ └── chat-unread-badge/
│ └── ui/ChatUnreadBadge.vue
├── stores/
│ └── chat.ts
├── plugins/
│ └── centrifugo.ts
└── pages/
└── cabinet/
└── messages/
├── index.vue
└── [id].vue
public/
└── sounds/
└── message.mp3Файлы для модификации:
nuxt.config.ts— centrifugoUrl в runtimeConfig + vite worker configapp/layouts/default.vue— ChatUnreadBadge в хедере (desktop + mobile)app/layouts/cabinet.vue— пункт "Сообщения" в sidebarapp/pages/product/[id].vue— ContactSellerButton вместо заглушкиi18n/locales/ru.json— ключиchat.*
Zod-схемы (по обновлённому API от 2026-02-12)
entities/message/model/message.schema.ts
typescript
import { z } from 'zod'
export const messageTypeSchema = z.enum(['text', 'image', 'system'])
export const messageStatusSchema = z.enum(['sent', 'delivered', 'read'])
export const messageSchema = z.object({
id: z.number(),
conversation_id: z.number(),
sender_id: z.number().nullable(), // null для system сообщений
type: messageTypeSchema,
text: z.string().nullable(),
image_url: z.string().nullable(),
image_thumbnail: z.string().nullable(),
status: messageStatusSchema,
created_at: z.string(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageType = z.infer<typeof messageTypeSchema>
export type MessageStatus = z.infer<typeof messageStatusSchema>entities/conversation/model/conversation.schema.ts
typescript
import { z } from 'zod'
import { messageSchema } from '~/entities/message/model/message.schema'
export const conversationProductSchema = z.object({
id: z.number(),
title: z.string(),
price: z.number(),
status: z.string(),
image_url: z.string().nullable(), // миниатюра товара
})
export const conversationCompanionSchema = z.object({
id: z.number(),
display_name: z.string(),
avatar_url: z.string().nullable(),
})
export const conversationSchema = z.object({
id: z.number(),
product: conversationProductSchema,
companion: conversationCompanionSchema,
last_message: messageSchema.nullable(),
unread_count: z.number(),
created_at: z.string(),
updated_at: z.string(),
})
export type Conversation = z.infer<typeof conversationSchema>
export type ConversationProduct = z.infer<typeof conversationProductSchema>
export type ConversationCompanion = z.infer<typeof conversationCompanionSchema>nuxt.config.ts — изменения
typescript
runtimeConfig: {
apiBaseServer: '',
devBasicAuth: '',
public: {
apiBase: '/api',
centrifugoUrl: '', // NUXT_PUBLIC_CENTRIFUGO_URL
},
},
vite: {
worker: { format: 'es' },
build: {
rollupOptions: { maxParallelFileOps: 2 },
},
},Env:
NUXT_PUBLIC_CENTRIFUGO_URL=wss://dev.partizap.ru/connection/websocket # dev
NUXT_PUBLIC_CENTRIFUGO_URL=wss://partizap.ru/connection/websocket # prodТакже добавить devProxy для Centrifugo WS (если нужен для localhost):
typescript
nitro: {
devProxy: {
'/api': { /* существующий */ },
'/connection': {
target: 'wss://dev.partizap.ru',
ws: true,
changeOrigin: true,
},
},
},stores/chat.ts
typescript
export const useChatStore = defineStore('chat', () => {
const totalUnread = ref(0)
// Онлайн-статусы собеседников: userId → { online, last_seen_at }
const presenceMap = ref<Map<number, { online: boolean; last_seen_at: string | null }>>(new Map())
function setTotalUnread(count: number) {
totalUnread.value = count
}
async function fetchUnreadCount() {
const api = useApi()
const res = await api.get<{ data: { total_unread: number } }>(
'/vendor/conversations/unread-count',
)
totalUnread.value = res.data.total_unread
}
function setPresence(userId: number, online: boolean, lastSeenAt: string | null) {
presenceMap.value.set(userId, { online, last_seen_at: lastSeenAt })
}
function getPresence(userId: number) {
return presenceMap.value.get(userId) ?? null
}
return {
totalUnread: readonly(totalUnread),
presenceMap: readonly(presenceMap),
setTotalUnread,
fetchUnreadCount,
setPresence,
getPresence,
}
})SharedWorker: chat.shared-worker.ts
centrifuge-js v5 работает внутри Worker. Куки пробрасываются автоматически.
typescript
import { Centrifuge } from 'centrifuge'
const ports: MessagePort[] = []
let client: Centrifuge | null = null
function broadcast(event: WorkerEvent) {
for (const port of ports) port.postMessage(event)
}
function connectClient(url: string) {
if (client) return
client = new Centrifuge(url)
client.on('connected', () => broadcast({ event: 'connected' }))
client.on('disconnected', (ctx) => broadcast({ event: 'disconnected', reason: ctx.reason }))
client.on('publication', (ctx) => {
broadcast({ event: 'publication', channel: ctx.channel, data: ctx.data })
})
client.connect()
}
function disconnectClient() {
client?.disconnect()
client = null
}
// @ts-expect-error SharedWorker global
self.onconnect = (e: MessageEvent) => {
const port = e.ports[0]
ports.push(port)
port.onmessage = (msg) => {
const { cmd, url } = msg.data
if (cmd === 'connect') connectClient(url)
if (cmd === 'disconnect') disconnectClient()
}
if (client?.state === 'connected') {
port.postMessage({ event: 'connected' })
}
port.start()
}
type WorkerEvent =
| { event: 'connected' }
| { event: 'disconnected'; reason: string }
| { event: 'publication'; channel: string; data: unknown }useCentrifugo() composable
typescript
import { Centrifuge } from 'centrifuge'
type EventHandler = (data: unknown) => void
export function useCentrifugo() {
const config = useRuntimeConfig()
const isConnected = ref(false)
const handlers = new Map<string, Set<EventHandler>>()
let worker: SharedWorker | null = null
let directClient: Centrifuge | null = null
function handleMessage(msg: { event: string; data?: unknown; channel?: string; reason?: string }) {
if (msg.event === 'connected') isConnected.value = true
if (msg.event === 'disconnected') isConnected.value = false
if (msg.event === 'publication' && msg.data) {
const payload = msg.data as { type: string; data: unknown }
handlers.get(payload.type)?.forEach(fn => fn(payload.data))
}
}
function connect() {
const url = config.public.centrifugoUrl as string
if (!url) return
if (typeof SharedWorker !== 'undefined') {
worker = new SharedWorker(
new URL('../worker/chat.shared-worker.ts', import.meta.url),
{ type: 'module', name: 'partizap-chat' },
)
worker.port.onmessage = (e) => handleMessage(e.data)
worker.port.start()
worker.port.postMessage({ cmd: 'connect', url })
} else {
// Fallback: прямое подключение (Safari Private)
directClient = new Centrifuge(url)
directClient.on('connected', () => { isConnected.value = true })
directClient.on('disconnected', () => { isConnected.value = false })
directClient.on('publication', (ctx) => {
const payload = ctx.data as { type: string; data: unknown }
handlers.get(payload.type)?.forEach(fn => fn(payload.data))
})
directClient.connect()
}
}
function disconnect() {
worker?.port.postMessage({ cmd: 'disconnect' })
directClient?.disconnect()
}
function onEvent(type: string, handler: EventHandler) {
if (!handlers.has(type)) handlers.set(type, new Set())
handlers.get(type)!.add(handler)
}
function offEvent(type: string, handler?: EventHandler) {
if (handler) handlers.get(type)?.delete(handler)
else handlers.delete(type)
}
return { isConnected: readonly(isConnected), connect, disconnect, onEvent, offEvent }
}plugins/centrifugo.ts
typescript
export default defineNuxtPlugin(() => {
if (import.meta.server) return
const authStore = useAuthStore()
const chatStore = useChatStore()
// auth.ts плагин выполняется раньше (a < c по алфавиту)
if (!authStore.isAuthenticated) return
chatStore.fetchUnreadCount().catch(() => {})
const { connect, onEvent } = useCentrifugo()
// Глобальные обработчики: unread count + presence
onEvent('unread.update', (data: unknown) => {
const { total_unread } = data as { total_unread: number }
chatStore.setTotalUnread(total_unread)
})
onEvent('presence', (data: unknown) => {
const { user_id, online, last_seen_at } = data as {
user_id: number
online: boolean
last_seen_at: string | null
}
chatStore.setPresence(user_id, online, last_seen_at)
})
connect()
})useConversations()
typescript
export function useConversations() {
const api = useApi()
const { onEvent } = useCentrifugo()
const chatStore = useChatStore()
const filter = ref<'all' | 'unread'>('all')
const conversations = ref<Conversation[]>([])
const hasMore = ref(false)
const isLoading = ref(false)
const cursor = ref<string | null>(null)
async function load(reset = false) {
if (isLoading.value) return
if (reset) { conversations.value = []; cursor.value = null }
isLoading.value = true
const params: Record<string, unknown> = { limit: 20 }
if (cursor.value) params.cursor = cursor.value
if (filter.value === 'unread') params.filter = 'unread'
const res = await api.get<ApiListResponse<Conversation>>('/vendor/conversations', params)
conversations.value = reset ? res.data : [...conversations.value, ...res.data]
hasMore.value = res.meta.has_more
cursor.value = res.meta.next_cursor
isLoading.value = false
}
function refresh() { return load(true) }
function loadMore() { if (hasMore.value && !isLoading.value) load(false) }
// Realtime: новое сообщение
onEvent('message.new', (data: unknown) => {
const { conversation_id, message } = data as { conversation_id: number; message: Message }
const idx = conversations.value.findIndex(c => c.id === conversation_id)
if (idx !== -1) {
const conv = { ...conversations.value[idx], last_message: message, updated_at: message.created_at }
// Инкремент unread если не моё сообщение (и не system)
const authStore = useAuthStore()
if (message.sender_id !== authStore.user?.id) conv.unread_count += 1
conversations.value.splice(idx, 1)
conversations.value.unshift(conv)
} else {
refresh() // новый диалог, перезагрузить
}
})
// Realtime: сообщения доставлены
onEvent('message.delivered', (data: unknown) => {
const { conversation_id, last_message_id } = data as { conversation_id: number; last_message_id: number }
const conv = conversations.value.find(c => c.id === conversation_id)
if (conv?.last_message && conv.last_message.id <= last_message_id && conv.last_message.status === 'sent') {
conv.last_message = { ...conv.last_message, status: 'delivered' }
}
})
// Realtime: прочитано — обновляем unread_count и статус last_message
onEvent('message.read', (data: unknown) => {
const { conversation_id, last_read_message_id } = data as {
conversation_id: number
reader_id: number
last_read_message_id: number
}
const conv = conversations.value.find(c => c.id === conversation_id)
if (!conv) return
// Обнулить unread, если мы прочитали
const authStore = useAuthStore()
if (data && (data as { reader_id: number }).reader_id === authStore.user?.id) {
conv.unread_count = 0
}
// Обновить статус last_message если мы отправитель
if (conv.last_message && conv.last_message.sender_id === authStore.user?.id
&& conv.last_message.id <= last_read_message_id) {
conv.last_message = { ...conv.last_message, status: 'read' }
}
})
// Realtime: unread count обновлён
onEvent('unread.update', (data: unknown) => {
const { total_unread } = data as { total_unread: number }
chatStore.setTotalUnread(total_unread)
})
watch(filter, () => refresh())
return {
conversations: readonly(conversations),
filter, hasMore: readonly(hasMore), isLoading: readonly(isLoading),
refresh, loadMore,
}
}useMessages(conversationId)
typescript
export function useMessages(conversationId: Ref<number>) {
const api = useApi()
const authStore = useAuthStore()
const { onEvent } = useCentrifugo()
const messages = ref<Message[]>([])
const hasMore = ref(true)
const isLoading = ref(false)
const cursor = ref<string | null>(null)
async function loadOlder() {
if (isLoading.value || !hasMore.value) return
isLoading.value = true
const params: Record<string, unknown> = { limit: 50 }
if (cursor.value) params.cursor = cursor.value
const res = await api.get<ApiListResponse<Message>>(
`/vendor/conversations/${conversationId.value}/messages`, params,
)
// API returns newest first -> reverse for display (oldest on top)
messages.value = [...res.data.reverse(), ...messages.value]
hasMore.value = res.meta.has_more
cursor.value = res.meta.next_cursor
isLoading.value = false
}
// Optimistic send
let tempIdCounter = -1
async function send(text: string) {
const tempId = tempIdCounter--
const optimistic: Message = {
id: tempId,
conversation_id: conversationId.value,
sender_id: authStore.user!.id,
type: 'text',
text,
image_url: null,
image_thumbnail: null,
status: 'sent',
created_at: new Date().toISOString(),
}
messages.value.push(optimistic)
try {
const res = await api.post<ApiItemResponse<Message>>(
`/vendor/conversations/${conversationId.value}/messages`, { text },
)
const idx = messages.value.findIndex(m => m.id === tempId)
if (idx !== -1) messages.value[idx] = res.data
} catch {
const idx = messages.value.findIndex(m => m.id === tempId)
if (idx !== -1) {
// Маркер ошибки для retry UI (через _error на объекте)
;(messages.value[idx] as Message & { _error?: boolean })._error = true
}
}
}
async function sendImage(file: File) {
const formData = new FormData()
formData.append('image', file)
const res = await api.upload<ApiItemResponse<Message>>(
`/vendor/conversations/${conversationId.value}/messages/image`, formData,
)
messages.value.push(res.data)
}
async function markRead() {
await api.post(`/vendor/conversations/${conversationId.value}/read`).catch(() => {})
}
// Подтверждение доставки: вызывается при получении message.new от собеседника
async function confirmDelivery(lastMessageId: number) {
await api.post(`/vendor/conversations/${conversationId.value}/delivered`, {
last_message_id: lastMessageId,
}).catch(() => {})
}
// Realtime: новое сообщение в этом диалоге
onEvent('message.new', (data: unknown) => {
const { conversation_id, message } = data as { conversation_id: number; message: Message }
if (conversation_id !== conversationId.value) return
// Для своих сообщений: не дублировать (optimistic уже есть), но обновить ID
if (message.sender_id === authStore.user?.id) {
// Проверить, есть ли optimistic с тем же текстом (отправленный из другой вкладки)
const hasOptimistic = messages.value.some(m => m.id < 0 && m.text === message.text)
if (hasOptimistic) {
const idx = messages.value.findIndex(m => m.id < 0 && m.text === message.text)
if (idx !== -1) messages.value[idx] = message
} else {
// Сообщение из другой вкладки — добавить если ещё нет
if (!messages.value.some(m => m.id === message.id)) {
messages.value.push(message)
}
}
return
}
// Сообщение от собеседника или системное
if (!messages.value.some(m => m.id === message.id)) {
messages.value.push(message)
}
// Подтвердить доставку (batch)
confirmDelivery(message.id)
// Автоматически прочитать если чат открыт
markRead()
})
// Realtime: мои сообщения доставлены собеседнику
onEvent('message.delivered', (data: unknown) => {
const { conversation_id, last_message_id } = data as { conversation_id: number; last_message_id: number }
if (conversation_id !== conversationId.value) return
for (const msg of messages.value) {
if (msg.sender_id === authStore.user?.id && msg.status === 'sent' && msg.id <= last_message_id) {
msg.status = 'delivered'
}
}
})
// Realtime: собеседник прочитал мои сообщения
onEvent('message.read', (data: unknown) => {
const { conversation_id, reader_id, last_read_message_id } = data as {
conversation_id: number
reader_id: number
last_read_message_id: number
}
if (conversation_id !== conversationId.value) return
if (reader_id === authStore.user?.id) return
// Обновить статус только до last_read_message_id
for (const msg of messages.value) {
if (msg.sender_id === authStore.user?.id && msg.id <= last_read_message_id) {
msg.status = 'read'
}
}
})
// Сброс при смене диалога
watch(conversationId, () => {
messages.value = []
cursor.value = null
hasMore.value = true
loadOlder()
}, { immediate: true })
return {
messages: readonly(messages), hasMore: readonly(hasMore), isLoading: readonly(isLoading),
loadOlder, send, sendImage, markRead,
}
}useTyping(conversationId)
typescript
export function useTyping(conversationId: Ref<number>) {
const api = useApi()
const authStore = useAuthStore()
const { onEvent } = useCentrifugo()
const companionTyping = ref(false)
let clearTimer: ReturnType<typeof setTimeout> | null = null
let lastSent = 0
onEvent('typing', (data: unknown) => {
const { conversation_id, user_id } = data as { conversation_id: number; user_id: number }
if (conversation_id !== conversationId.value) return
if (user_id === authStore.user?.id) return
companionTyping.value = true
if (clearTimer) clearTimeout(clearTimer)
clearTimer = setTimeout(() => { companionTyping.value = false }, 4000)
})
// Сбросить typing при получении сообщения
onEvent('message.new', (data: unknown) => {
const { conversation_id } = data as { conversation_id: number }
if (conversation_id === conversationId.value) {
companionTyping.value = false
if (clearTimer) clearTimeout(clearTimer)
}
})
function sendTyping() {
const now = Date.now()
if (now - lastSent < 3000) return
lastSent = now
api.post(`/vendor/conversations/${conversationId.value}/typing`).catch(() => {})
}
return { companionTyping: readonly(companionTyping), sendTyping }
}useOnlineStatus(companionId)
Composable для online-статуса собеседника. Использует REST для начальной загрузки + realtime через presence event (уже обрабатывается в плагине → chatStore).
typescript
export function useOnlineStatus(companionId: Ref<number>) {
const api = useApi()
const chatStore = useChatStore()
const online = ref(false)
const lastSeenAt = ref<string | null>(null)
// Начальная загрузка через REST
async function fetchStatus() {
try {
const res = await api.get<{ data: { online: boolean; last_seen_at: string | null } }>(
`/vendor/users/${companionId.value}/online`,
)
online.value = res.data.online
lastSeenAt.value = res.data.last_seen_at
// Сохранить в store для синхронизации с presence events
chatStore.setPresence(companionId.value, res.data.online, res.data.last_seen_at)
} catch {
// Не блокировать UI при ошибке
}
}
// Реактивно отслеживать presence events через store
watch(
() => chatStore.getPresence(companionId.value),
(presence) => {
if (presence) {
online.value = presence.online
lastSeenAt.value = presence.last_seen_at
}
},
)
// Форматирование "был(а) ..."
const lastSeenLabel = computed(() => {
if (online.value) return null
if (!lastSeenAt.value) return null
const date = new Date(lastSeenAt.value)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const isYesterday = date.toDateString() === yesterday.toDateString()
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
if (isToday) return `сегодня в ${time}`
if (isYesterday) return `вчера в ${time}`
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) + ` в ${time}`
})
// Загрузить при смене собеседника
watch(companionId, () => fetchStatus(), { immediate: true })
return { online: readonly(online), lastSeenAt: readonly(lastSeenAt), lastSeenLabel }
}useChatNotifications()
typescript
export function useChatNotifications() {
const { onEvent } = useCentrifugo()
const chatStore = useChatStore()
const route = useRoute()
const authStore = useAuthStore()
let audio: HTMLAudioElement | null = null
onEvent('message.new', (data: unknown) => {
const { conversation_id, message } = data as { conversation_id: number; message: Message }
// Не звук на свои сообщения и системные
if (message.sender_id === authStore.user?.id) return
if (message.type === 'system') return
// Не звук если мы в этом чате
const currentConvId = route.params.id ? Number(route.params.id) : null
if (conversation_id === currentConvId) return
if (!audio) audio = new Audio('/sounds/message.mp3')
audio.play().catch(() => {})
})
onEvent('unread.update', (data: unknown) => {
const { total_unread } = data as { total_unread: number }
chatStore.setTotalUnread(total_unread)
})
}Entity UI: MessageStatus.vue
3 статуса: sent (одна серая галочка), delivered (две серые галочки), read (две синие галочки).
html
<script setup lang="ts">
import type { MessageStatus as StatusType } from '~/entities/message/model/message.schema'
defineProps<{ status: StatusType }>()
</script>
<template>
<span class="inline-flex">
<!-- sent: одна серая галочка -->
<UIcon v-if="status === 'sent'" name="i-lucide-check" class="w-3.5 h-3.5 opacity-60" />
<!-- delivered: две серые галочки -->
<UIcon v-else-if="status === 'delivered'" name="i-lucide-check-check" class="w-3.5 h-3.5 opacity-60" />
<!-- read: две синие галочки -->
<UIcon v-else-if="status === 'read'" name="i-lucide-check-check" class="w-3.5 h-3.5 text-blue-400" />
</span>
</template>Entity UI: MessageBubble.vue
Поддерживает 3 типа: text, image, system.
html
<script setup lang="ts">
import type { Message } from '~/entities/message/model/message.schema'
const props = defineProps<{ message: Message; isMine: boolean }>()
const timeLabel = computed(() => {
const date = new Date(props.message.created_at)
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
})
const isSystem = computed(() => props.message.type === 'system')
</script>
<template>
<!-- System message: по центру, без пузыря -->
<div v-if="isSystem" class="flex justify-center my-2">
<div class="px-3 py-1.5 rounded-full bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 text-center max-w-[85%]">
{{ message.text }}
</div>
</div>
<!-- Regular message bubble -->
<div v-else class="flex" :class="isMine ? 'justify-end' : 'justify-start'">
<div
class="max-w-[75%] rounded-2xl px-4 py-2"
:class="isMine
? 'bg-primary text-white rounded-br-md'
: 'bg-gray-100 dark:bg-gray-800 text-[var(--ui-text)] rounded-bl-md'"
>
<img
v-if="message.type === 'image' && message.image_url"
:src="message.image_url"
class="rounded-lg max-w-full max-h-64 object-contain cursor-pointer"
/>
<p v-if="message.text" class="whitespace-pre-wrap break-words text-sm">
{{ message.text }}
</p>
<div class="flex items-center justify-end gap-1 mt-1">
<span class="text-[10px] opacity-70">{{ timeLabel }}</span>
<MessageStatus v-if="isMine" :status="message.status" />
</div>
</div>
</div>
</template>Entity UI: ConversationCard.vue
С миниатюрой товара (product.image_url).
html
<script setup lang="ts">
import type { Conversation } from '~/entities/conversation/model/conversation.schema'
const props = defineProps<{ conversation: Conversation }>()
const { t } = useI18n()
const timeLabel = computed(() => {
if (!props.conversation.last_message) return ''
const date = new Date(props.conversation.last_message.created_at)
const now = new Date()
if (date.toDateString() === now.toDateString())
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
})
const preview = computed(() => {
const msg = props.conversation.last_message
if (!msg) return t('chat.noMessages')
if (msg.type === 'system') return msg.text ?? ''
if (msg.type === 'image') return t('chat.attachImage')
return msg.text ?? ''
})
</script>
<template>
<NuxtLink
:to="`/cabinet/messages/${conversation.id}`"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<!-- Аватар собеседника -->
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex-shrink-0 flex items-center justify-center overflow-hidden">
<img v-if="conversation.companion.avatar_url" :src="conversation.companion.avatar_url" class="w-full h-full object-cover" />
<UIcon v-else name="i-lucide-user" class="w-6 h-6 text-gray-400" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-medium truncate">{{ conversation.companion.display_name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">{{ timeLabel }}</span>
</div>
<!-- Товар: миниатюра + название + цена -->
<div class="flex items-center gap-2 mt-0.5">
<img
v-if="conversation.product.image_url"
:src="conversation.product.image_url"
class="w-6 h-6 rounded object-cover flex-shrink-0"
/>
<span class="text-sm text-gray-500 dark:text-gray-400 truncate">
{{ conversation.product.title }} · {{ conversation.product.price.toLocaleString('ru-RU') }} ₽
</span>
</div>
<!-- Превью последнего сообщения + unread badge -->
<div class="flex items-center justify-between mt-0.5">
<span class="text-sm truncate" :class="conversation.unread_count > 0 ? 'font-medium text-[var(--ui-text)]' : 'text-gray-500 dark:text-gray-400'">
{{ preview }}
</span>
<span
v-if="conversation.unread_count > 0"
class="flex-shrink-0 ml-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-bold text-white"
>
{{ conversation.unread_count }}
</span>
</div>
</div>
</NuxtLink>
</template>Feature UI: ContactSellerButton.vue
Заменяет заглушку на product/[id].vue.
html
<script setup lang="ts">
import type { Conversation } from '~/entities/conversation/model/conversation.schema'
import type { ApiItemResponse } from '~/shared/api/types'
const props = defineProps<{ productId: number }>()
const api = useApi()
const authStore = useAuthStore()
const { t } = useI18n()
const isLoading = ref(false)
async function contactSeller() {
if (!authStore.isAuthenticated) {
await navigateTo('/auth/login')
return
}
isLoading.value = true
try {
const res = await api.post<ApiItemResponse<Conversation>>(
'/vendor/conversations',
{ product_id: props.productId },
)
await navigateTo(`/cabinet/messages/${res.data.id}`)
} finally {
isLoading.value = false
}
}
</script>
<template>
<UButton color="primary" size="lg" :loading="isLoading" @click="contactSeller">
{{ t('chat.writeToSeller') }}
</UButton>
</template>Widget: ChatUnreadBadge.vue
html
<script setup lang="ts">
const chatStore = useChatStore()
const { t } = useI18n()
</script>
<template>
<div class="relative">
<UButton to="/cabinet/messages" variant="ghost" icon="i-lucide-message-circle" :aria-label="t('chat.title')" />
<span
v-if="chatStore.totalUnread > 0"
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white"
>
{{ chatStore.totalUnread > 99 ? '99+' : chatStore.totalUnread }}
</span>
</div>
</template>Page: /cabinet/messages/index.vue
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
const { t } = useI18n()
useSeoMeta({ title: () => t('chat.title') })
const { conversations, filter, hasMore, isLoading, refresh, loadMore } = useConversations()
const sentinel = ref<HTMLElement | null>(null)
const { stop } = useIntersectionObserver(sentinel, ([entry]) => {
if (entry?.isIntersecting) loadMore()
})
onUnmounted(stop)
onMounted(refresh)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">{{ t('chat.title') }}</h1>
</div>
<div class="flex gap-2 mb-4">
<UButton :variant="filter === 'all' ? 'solid' : 'ghost'" size="sm" :label="t('chat.filterAll')" @click="filter = 'all'" />
<UButton :variant="filter === 'unread' ? 'solid' : 'ghost'" size="sm" :label="t('chat.filterUnread')" @click="filter = 'unread'" />
</div>
<div v-if="conversations.length" class="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
<ConversationCard v-for="conv in conversations" :key="conv.id" :conversation="conv" />
</div>
<div v-else-if="!isLoading" class="text-center py-16">
<UIcon name="i-lucide-message-circle" class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ t('chat.noConversations') }}</p>
</div>
<div ref="sentinel" class="py-4 text-center">
<UButton v-if="isLoading" loading variant="ghost" />
</div>
</div>
</template>Page: /cabinet/messages/[id].vue
html
<script setup lang="ts">
import type { Conversation } from '~/entities/conversation/model/conversation.schema'
import type { ApiItemResponse } from '~/shared/api/types'
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
const route = useRoute()
const conversationId = computed(() => Number(route.params.id))
const { t } = useI18n()
const api = useApi()
const authStore = useAuthStore()
const { data: conversation } = await useAsyncData(
`conversation-${conversationId.value}`,
() => api.get<ApiItemResponse<Conversation>>(`/vendor/conversations/${conversationId.value}`).then(r => r.data),
)
useSeoMeta({ title: () => conversation.value?.product.title ?? t('chat.title') })
const { messages, hasMore, isLoading, loadOlder, send, sendImage, markRead } = useMessages(conversationId)
const { companionTyping, sendTyping } = useTyping(conversationId)
// Online status: REST + realtime presence events
const companionId = computed(() => conversation.value?.companion.id ?? 0)
const { online: companionOnline, lastSeenLabel } = useOnlineStatus(companionId)
const inputText = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
async function handleSend() {
const text = inputText.value.trim()
if (!text) return
inputText.value = ''
await send(text)
scrollToBottom()
}
function handleFileSelect(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
sendImage(file)
scrollToBottom()
}
function scrollToBottom() {
nextTick(() => {
messagesContainer.value?.scrollTo({ top: messagesContainer.value.scrollHeight, behavior: 'smooth' })
})
}
onMounted(() => { markRead(); scrollToBottom() })
watch(() => messages.value.length, () => {
const el = messagesContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) scrollToBottom()
})
</script>
<template>
<div class="flex flex-col h-[calc(100vh-8rem)]">
<!-- Header -->
<div class="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-800">
<UButton to="/cabinet/messages" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
<div v-if="conversation" class="flex items-center gap-3 flex-1 min-w-0">
<div class="relative w-10 h-10 flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
<img v-if="conversation.companion.avatar_url" :src="conversation.companion.avatar_url" class="w-full h-full object-cover" />
<UIcon v-else name="i-lucide-user" class="w-5 h-5 text-gray-400" />
</div>
<!-- Online indicator dot -->
<span
v-if="companionOnline"
class="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-green-500 border-2 border-white dark:border-gray-900"
/>
</div>
<div class="min-w-0">
<div class="font-medium truncate">{{ conversation.companion.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<span v-if="companionTyping" class="text-primary">{{ t('chat.typing') }}</span>
<span v-else-if="companionOnline" class="text-green-500">{{ t('chat.online') }}</span>
<span v-else-if="lastSeenLabel">{{ t('chat.lastSeen', { time: lastSeenLabel }) }}</span>
<span v-else>{{ conversation.product.title }} · {{ conversation.product.price.toLocaleString('ru-RU') }} ₽</span>
</div>
</div>
</div>
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4 space-y-2">
<div v-if="hasMore" class="text-center">
<UButton :loading="isLoading" variant="ghost" size="sm" label="Загрузить ранее" @click="loadOlder" />
</div>
<MessageBubble
v-for="msg in messages"
:key="msg.id"
:message="msg"
:is-mine="msg.sender_id === authStore.user?.id"
/>
</div>
<!-- Input -->
<div class="pt-3 border-t border-gray-200 dark:border-gray-800">
<div class="flex items-end gap-2">
<label class="cursor-pointer">
<UButton variant="ghost" icon="i-lucide-paperclip" as="span" />
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="handleFileSelect" />
</label>
<UTextarea
v-model="inputText"
:placeholder="t('chat.placeholder')"
autoresize
:rows="1"
:maxrows="5"
class="flex-1"
@input="sendTyping"
@keydown.enter.exact.prevent="handleSend"
/>
<UButton icon="i-lucide-send" color="primary" :disabled="!inputText.trim()" @click="handleSend" />
</div>
</div>
</div>
</template>Интеграция в существующий код
layouts/default.vue — бейдж в desktop хедер
Вставить после favorites badge (строка ~55), перед user button:
html
<ChatUnreadBadge v-if="authStore.isAuthenticated" />layouts/default.vue — мобильное меню
Вставить после favorites (строка ~153):
html
<UButton
:label="t('chat.title')"
to="/cabinet/messages"
variant="ghost"
block
class="justify-start"
icon="i-lucide-message-circle"
@click="mobileMenuOpen = false"
/>layouts/cabinet.vue — sidebar
Вставить после favorites (строка ~40):
html
<UButton label="Сообщения" to="/cabinet/messages" variant="ghost" block icon="i-lucide-message-circle" />pages/product/[id].vue — заменить заглушку
Заменить строки 131-133:
html
<!-- Было -->
<UButton color="primary" size="lg" @click="handleContactSeller">
<!-- Стало -->
<ContactSellerButton :product-id="product.id" />Удалить функцию handleContactSeller() (строки 14-20).
i18n/locales/ru.json
Добавить секцию:
json
"chat": {
"title": "Сообщения",
"writeToSeller": "Написать продавцу",
"placeholder": "Введите сообщение...",
"typing": "печатает...",
"online": "в сети",
"lastSeen": "был(а) {time}",
"noConversations": "Нет сообщений",
"noMessages": "Начните диалог",
"filterAll": "Все",
"filterUnread": "Непрочитанные",
"attachImage": "Фото",
"sendError": "Не удалось отправить",
"retry": "Повторить",
"imageUploadError": "Не удалось загрузить изображение",
"messageTooLong": "Сообщение слишком длинное (макс. 2000 символов)",
"productSold": "Товар продан",
"loadEarlier": "Загрузить ранее",
"deleteConversation": "Удалить диалог",
"deleteConfirm": "Диалог будет скрыт. Если собеседник напишет снова, диалог восстановится."
}Порядок реализации
Фаза 1 — Фундамент (нет зависимости от WS, можно сразу)
1. npm install centrifuge
2. entities/message/model/message.schema.ts
3. entities/conversation/model/conversation.schema.ts
4. nuxt.config.ts: centrifugoUrl + vite worker config
5. i18n/locales/ru.json: ключи chat.*
6. stores/chat.ts (с presenceMap)
Фаза 2 — Realtime ядро
7. features/chat-connection/worker/chat.shared-worker.ts
8. features/chat-connection/composables/useCentrifugo.ts
9. plugins/centrifugo.ts (unread.update + presence)
10. features/chat-messaging/composables/useChatNotifications.ts
Фаза 3 — UI компоненты
11. entities/message/ui/MessageStatus.vue (sent/delivered/read — 3 состояния)
12. entities/message/ui/MessageBubble.vue (text/image/system — 3 типа)
13. entities/conversation/ui/ConversationCard.vue (с product.image_url)
14. widgets/chat-unread-badge/ui/ChatUnreadBadge.vue
15. features/contact-seller/ui/ContactSellerButton.vue
Фаза 4 — Composables + страницы
16. features/chat-messaging/composables/useConversations.ts (message.new + message.delivered + message.read)
17. features/chat-messaging/composables/useMessages.ts (+ confirmDelivery + message.delivered handler)
18. features/chat-messaging/composables/useTyping.ts
19. features/chat-messaging/composables/useOnlineStatus.ts (REST + presence)
20. pages/cabinet/messages/index.vue
21. pages/cabinet/messages/[id].vue (+ useOnlineStatus + lastSeenLabel)
Фаза 5 — Интеграция
22. layouts/default.vue: ChatUnreadBadge (desktop + mobile)
23. layouts/cabinet.vue: пункт "Сообщения"
24. pages/product/[id].vue: ContactSellerButton
25. public/sounds/message.mp3
Фаза 6 — Полировка
26. features/chat-image/composables/useChatImageUpload.ts (превью blob, прогресс)
27. Dark mode проверка всех компонентов
28. Loading skeletons и empty states
29. Тест: system messages отображаются корректно
30. Тест: delivered/read статусы корректно обновляются