Skip to content

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 config
  • app/layouts/default.vue — ChatUnreadBadge в хедере (desktop + mobile)
  • app/layouts/cabinet.vue — пункт "Сообщения" в sidebar
  • app/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%]">
      &#123;&#123; 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">
        &#123;&#123; message.text }}
      </p>
      <div class="flex items-center justify-end gap-1 mt-1">
        <span class="text-[10px] opacity-70">&#123;&#123; 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">&#123;&#123; conversation.companion.display_name }}</span>
        <span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">&#123;&#123; 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">
          &#123;&#123; conversation.product.title }} · &#123;&#123; 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'">
          &#123;&#123; 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"
        >
          &#123;&#123; 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">
    &#123;&#123; 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"
    >
      &#123;&#123; 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">&#123;&#123; 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">&#123;&#123; 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">&#123;&#123; conversation.companion.display_name }}</div>
          <div class="text-xs text-gray-500 dark:text-gray-400">
            <span v-if="companionTyping" class="text-primary">&#123;&#123; t('chat.typing') }}</span>
            <span v-else-if="companionOnline" class="text-green-500">&#123;&#123; t('chat.online') }}</span>
            <span v-else-if="lastSeenLabel">&#123;&#123; t('chat.lastSeen', { time: lastSeenLabel }) }}</span>
            <span v-else>&#123;&#123; conversation.product.title }} · &#123;&#123; 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 статусы корректно обновляются