Appearance
Chat Feature — Frontend Developer Guide
Everything a Nuxt frontend developer needs to build the buyer-seller chat.
Design doc: docs/2026-02-10-chat-design.md (full architecture, SharedWorker diagrams, FSD structure, user flows)
Overview
Real-time 1-to-1 messaging between buyer and seller, tied to a product. One conversation per (buyer, product) pair. Real-time delivery via Centrifugo WebSocket server.
Key points:
- All REST endpoints require authentication (session cookie)
- WebSocket auth uses the same session cookie (Centrifugo connect proxy validates it)
- All events arrive on a single personal channel
personal:#<userId> - No per-conversation channels — frontend routes events by
conversation_idfield
WebSocket Connection (Centrifugo)
Endpoint
| Environment | WebSocket URL |
|---|---|
| Development | wss://dev.partizap.ru/connection/websocket |
| Production | wss://partizap.ru/connection/websocket |
How it works
- Frontend opens WebSocket via
centrifuge-js— browser cookies are sent automatically - Centrifugo calls the backend connect proxy (
POST /api/centrifugo/connect) - Backend validates
PARTIZAP_SESSIONcookie, returns{ user: "42", channels: ["personal:#42"] } - Centrifugo establishes connection and auto-subscribes to
personal:#42 - All chat events arrive on this single channel
centrifuge-js setup
bash
npm install centrifugetypescript
import { Centrifuge } from 'centrifuge'
const centrifuge = new Centrifuge('wss://dev.partizap.ru/connection/websocket', {
// No token needed — auth via session cookie
})
// Listen for all events on personal channel
centrifuge.on('publication', (ctx) => {
const event = ctx.data // { type: string, data: {...} }
switch (event.type) {
case 'message.new':
// Handle new message
break
case 'typing':
// Handle typing indicator
break
case 'message.read':
// Handle read receipt
break
case 'unread.update':
// Handle total unread count change
break
}
})
centrifuge.connect()SharedWorker (recommended)
Use a SharedWorker to maintain one WebSocket across all tabs. See docs/2026-02-10-chat-design.md section "SharedWorker" for the full protocol and fallback strategy.
Reconnection
centrifuge-js handles reconnection automatically with exponential backoff (1s, 2s, 4s, 8s, max 30s). After reconnect, Centrifugo delivers missed events from its internal cache. If the cache expired, call refresh() on conversations list and current chat.
Data Types
Conversation
typescript
interface Conversation {
id: number
product: {
id: number
title: string
price: number // float
status: string // "draft" | "pending" | "active" | "sold" | "archived" | "rejected"
}
companion: {
id: number
display_name: string
avatar_url: string | null
}
last_message: Message | null
unread_count: number
created_at: string // ISO 8601, e.g. "2026-02-11T18:51:59+00:00"
updated_at: string // ISO 8601
}Message
typescript
interface Message {
id: number
conversation_id: number
sender_id: number
type: 'text' | 'image' | 'system'
text: string | null
image_url: string | null // full-size image (CDN URL)
image_thumbnail: string | null // 200×200 max thumbnail (CDN URL, separate file)
status: 'sent' | 'delivered' | 'read'
created_at: string // ISO 8601
}Zod Schemas
typescript
// entities/message/model/message.schema.ts
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(),
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>
// entities/conversation/model/conversation.schema.ts
export const conversationSchema = z.object({
id: z.number(),
product: z.object({
id: z.number(),
title: z.string(),
price: z.number(),
status: z.string(),
image_url: z.string().nullable(),
}),
companion: z.object({
id: z.number(),
display_name: z.string(),
avatar_url: z.string().nullable(),
}),
last_message: messageSchema.nullable(),
unread_count: z.number(),
created_at: z.string(),
updated_at: z.string(),
})
export type Conversation = z.infer<typeof conversationSchema>REST API Endpoints
All endpoints are under /api/vendor/ and require authentication (session cookie + CSRF token for POST/PUT/DELETE).
Conversations
POST /api/vendor/conversations — Create or find conversation
Start a conversation about a product. Returns existing conversation if one exists.
typescript
// Request
await api.post('/vendor/conversations', { product_id: 123 })| Field | Type | Required | Notes |
|---|---|---|---|
product_id | number | yes | Must be a valid product. Cannot be your own product. |
Response: { data: Conversation } with status 201 (created) or 200 (existing).
If the buyer previously soft-deleted the conversation, it is restored automatically.
Errors:
422—product_idmissing or messaging yourself404— product not found
Frontend flow:
Product page → "Write to seller" button → POST /conversations → redirect to /cabinet/messages/:idGET /api/vendor/conversations — List conversations
typescript
// Request
await api.get('/vendor/conversations', { limit: 20, cursor: '...', filter: 'unread' })| Param | Type | Default | Notes |
|---|---|---|---|
limit | number | 20 | Max 100 |
cursor | string | — | Opaque, from meta.next_cursor |
filter | string | — | "unread" = only with unread messages |
Response: { data: Conversation[], meta: { has_more: boolean, next_cursor: string | null } }
Sorted by updated_at DESC (most recently active first). Soft-deleted conversations are excluded.
GET /api/vendor/conversations/{id} — Get conversation
typescript
await api.get(`/vendor/conversations/${id}`)Response: { data: Conversation }
Errors: 404 (not found), 403 (not a participant)
DELETE /api/vendor/conversations/{id} — Soft-delete conversation
Hides conversation from the current user's list. Messages are preserved. If the companion sends a new message, the conversation reappears.
typescript
await api.delete(`/vendor/conversations/${id}`)Response: { data: { message: "Conversation deleted" } }
GET /api/vendor/conversations/unread-count — Total unread count
For the badge in the header/sidebar.
typescript
await api.get('/vendor/conversations/unread-count')Response:
json
{ "data": { "total_unread": 5 } }Messages
GET /api/vendor/conversations/{id}/messages — List messages
typescript
await api.get(`/vendor/conversations/${id}/messages`, { limit: 50, cursor: '...' })| Param | Type | Default | Notes |
|---|---|---|---|
limit | number | 50 | Max 100 |
cursor | string | — | Opaque, from meta.next_cursor |
Response: { data: Message[], meta: { has_more: boolean, next_cursor: string | null } }
Sorted by id DESC (newest first). Frontend should reverse for display (oldest on top).
POST /api/vendor/conversations/{id}/messages — Send text message
typescript
await api.post(`/vendor/conversations/${id}/messages`, { text: 'Hello!' })| Field | Type | Required | Notes |
|---|---|---|---|
text | string | yes | 1-2000 chars after trim. Whitespace-only rejected. |
Response: { data: Message } with status 201.
Side effects:
- Companion's unread count incremented
- Conversation's
last_messageandupdated_atupdated - If companion soft-deleted the conversation, it is restored
- Centrifugo
message.newevent published to both users (see below)
POST /api/vendor/conversations/{id}/messages/image — Send image message
typescript
const formData = new FormData()
formData.append('image', file)
await $fetch(`/api/vendor/conversations/${id}/messages/image`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: { 'X-CSRF-TOKEN': csrfToken },
})Request: multipart/form-data with image field.
Validation: Max 10 MB, JPEG/PNG/WebP only (magic byte verification). Image is re-encoded to strip EXIF metadata.
Response: { data: Message } with status 201. The message has type: "image", image_url (full-size, EXIF-stripped, CDN URL), and image_thumbnail (200×200 max, aspect-ratio preserved, separate CDN URL).
S3 paths: chat/{conversationId}/{hex}.{ext} (full) and chat/{conversationId}/{hex}_thumb.{ext} (thumbnail).
Same side effects as text message.
Actions
POST /api/vendor/conversations/{id}/read — Mark as read
Call when user opens a conversation or scrolls to unread messages.
typescript
await api.post(`/vendor/conversations/${id}/read`)Response: { data: { message: "Conversation marked as read" } }
Side effects:
- All companion's messages in this conversation set to status
"read"(including any with"delivered"status) - Current user's unread count for this conversation reset to 0
- Centrifugo
message.readevent published to companion (withlast_read_message_id) - Centrifugo
unread.updateevent published to current user
POST /api/vendor/conversations/{id}/delivered — Mark messages as delivered
Call when the client receives new messages (e.g. upon message.new event). Confirms receipt up to a given message ID.
typescript
await api.post(`/vendor/conversations/${id}/delivered`, {
last_message_id: 456
})| Field | Type | Required | Notes |
|---|---|---|---|
last_message_id | number | yes | All sent messages with id <= last_message_id from companion are set to delivered |
Response: { data: { message: "Messages marked as delivered" } }
Side effects:
- Companion's
sentmessages updated to"delivered" - Centrifugo
message.deliveredevent published to companion (withlast_message_id)
POST /api/vendor/conversations/{id}/typing — Send typing indicator
Call while user is typing. Frontend must debounce (recommended: send at most every 3 seconds, auto-clear after 4 seconds of inactivity).
typescript
await api.post(`/vendor/conversations/${id}/typing`)Response: { data: { message: "OK" } }
Nothing is stored in the database. A Centrifugo typing event is published to the companion.
GET /api/vendor/users/{id}/online — Check online status
typescript
await api.get(`/vendor/users/${userId}/online`)Response:
json
// Online
{ "data": { "online": true, "last_seen_at": null } }
// Offline
{ "data": { "online": false, "last_seen_at": "2026-02-11T18:30:00+00:00" } }User is considered online if last activity was < 5 minutes ago. The backend's OnlinePresenceMiddleware automatically updates user:online:{id} in Redis (300s TTL) on every authenticated /vendor/* request — no frontend heartbeat needed.
Centrifugo Events
All events arrive on the personal channel personal:#<userId>. Each event has { type, data } structure.
message.new
Fired when a message is sent. Published to both sender and companion.
json
{
"type": "message.new",
"data": {
"conversation_id": 123,
"message": {
"id": 456,
"conversation_id": 123,
"sender_id": 7,
"type": "text",
"text": "Hello!",
"image_url": null,
"image_thumbnail": null,
"status": "sent",
"created_at": "2026-02-11T18:51:59+00:00"
}
}
}Frontend handling:
- If conversation is open: append message, scroll down
- If conversation is in list: update
last_message, move to top, incrementunread_count - If not on chat page: increment header badge, play notification sound
- Sender receives this too (for tab sync via SharedWorker)
typing
Fired when companion is typing. Published to companion only.
json
{
"type": "typing",
"data": {
"conversation_id": 123,
"user_id": 7
}
}Frontend handling:
- Show "typing..." indicator for 4 seconds
- Reset timer on each new typing event
- Clear indicator when
message.newarrives for the same conversation
message.delivered
Fired when companion's device confirms receipt. Published to companion only (message sender).
json
{
"type": "message.delivered",
"data": {
"conversation_id": 123,
"last_message_id": 456
}
}Frontend handling:
- Update all own messages with
id <= last_message_idfromstatus: "sent"tostatus: "delivered"(show double grey check marks)
message.read
Fired when companion reads the conversation. Published to companion only (the one whose messages were read).
json
{
"type": "message.read",
"data": {
"conversation_id": 123,
"reader_id": 7,
"last_read_message_id": 456
}
}Frontend handling:
- Update all own messages with
id <= last_read_message_idtostatus: "read"(show double blue check marks) - Update conversation in list: set
unread_count: 0if viewer is the reader
unread.update
Fired when the current user's total unread count changes (after marking as read). Published to current user only.
json
{
"type": "unread.update",
"data": {
"total_unread": 3
}
}Frontend handling:
- Update header badge with new count
- If 0, hide badge
presence
Fired when a chat companion connects or disconnects from Centrifugo. Published to all companions (users with active conversations).
json
{
"type": "presence",
"data": {
"user_id": 7,
"online": false,
"last_seen_at": "2026-02-11T20:15:00+00:00"
}
}Frontend handling:
- Update online indicator for the companion in conversation list and open chat
- When
online: false, showlast_seen_atas "was online at..." - Note:
online: trueevents may come from regular API activity (OnlinePresenceMiddleware), whileonline: falseis broadcast on Centrifugo disconnect
User Flows
Flow 1: Buyer writes to seller
Product page → click "Write to seller"
→ POST /api/vendor/conversations { product_id }
→ 201 Created (new) or 200 OK (existing)
→ navigateTo(`/cabinet/messages/${conversation.id}`)
→ GET .../messages (load history)
→ User types → POST .../messages { text }
→ Message appears instantly (optimistic update)
→ Centrifugo delivers to sellerFlow 2: Receiving a message
Any page → SharedWorker receives message.new event
→ ChatUnreadBadge: increment counter
→ Play notification sound (if document.hidden)
→ /cabinet/messages page: move conversation to top, update last_message
→ /cabinet/messages/:id page: append message, scroll downFlow 3: Opening a conversation
/cabinet/messages → click conversation
→ navigateTo(`/cabinet/messages/${id}`)
→ GET /api/vendor/conversations/${id} (conversation details)
→ GET /api/vendor/conversations/${id}/messages (load messages)
→ POST /api/vendor/conversations/${id}/read (mark as read)
→ Centrifugo: message.read → companion, unread.update → selfFlow 4: Sending an image
Chat input → click attachment icon → file picker
→ Show local preview (URL.createObjectURL)
→ POST .../messages/image (multipart FormData)
→ Backend: validates image, strips EXIF, uploads full + 200×200 thumbnail to S3
→ Response: replace blob URL with image_url, use image_thumbnail for list preview
→ Centrifugo: message.new → companionFlow 5: Initial load (authenticated user)
App mount → SharedWorker.connect() → Centrifugo WebSocket
→ Auto-subscribe to personal:#userId
→ GET /api/vendor/conversations/unread-count → set header badgeOptimistic Updates
For POST .../messages:
- Immediately insert message into UI with a temporary negative
idandstatus: "sent" - Send POST request
- On success: replace temporary
idwith realidfrom response - On error: show error icon on the message with a "retry" button
typescript
// Example
const tempId = -(Date.now())
messages.value.push({
id: tempId,
conversation_id: conversationId,
sender_id: currentUserId,
type: 'text',
text: inputText,
image_url: null,
image_thumbnail: null,
status: 'sent',
created_at: new Date().toISOString(),
})
try {
const { data } = await api.post(`/vendor/conversations/${conversationId}/messages`, { text: inputText })
const idx = messages.value.findIndex(m => m.id === tempId)
if (idx !== -1) messages.value[idx] = data
} catch (e) {
const idx = messages.value.findIndex(m => m.id === tempId)
if (idx !== -1) messages.value[idx].status = 'error' // show retry button
}FSD Structure
entities/
├── conversation/
│ ├── model/conversation.schema.ts # Zod schema + types
│ └── ui/
│ └── ConversationCard.vue # Card in conversation list
└── message/
├── model/message.schema.ts # Zod schema + types
└── ui/
├── MessageBubble.vue # Bubble (text/image/system)
└── MessageStatus.vue # Check marks (sent/delivered/read)
features/
├── chat-connection/
│ ├── composables/useCentrifugo.ts # SharedWorker wrapper
│ └── worker/chat.shared-worker.ts # SharedWorker with centrifuge-js
├── chat-messaging/
│ ├── composables/useConversations.ts # List, filters, reactive updates
│ ├── composables/useMessages.ts # History, send, optimistic updates
│ ├── composables/useTyping.ts # Typing indicator (debounce 3s, clear 4s)
│ └── composables/useChatNotifications.ts # Sound + badge
├── chat-image/
│ └── composables/useChatImageUpload.ts
└── contact-seller/
└── ui/ContactSellerButton.vue # "Write to seller" on product page
widgets/
└── chat-unread-badge/
└── ui/ChatUnreadBadge.vue # Badge in header
pages/
└── cabinet/
└── messages/
├── index.vue # Conversation list (full screen)
└── [id].vue # Chat view (full screen, back button)i18n Keys (ru.json)
json
{
"chat": {
"title": "Сообщения",
"writeToSeller": "Написать продавцу",
"placeholder": "Введите сообщение...",
"send": "Отправить",
"typing": "печатает...",
"noConversations": "Нет сообщений",
"noMessages": "Начните диалог",
"filterAll": "Все",
"filterUnread": "Непрочитанные",
"deleteConversation": "Удалить диалог",
"deleteConfirm": "Диалог будет скрыт. Если собеседник напишет снова, диалог восстановится.",
"online": "в сети",
"lastSeen": "был(а) {time}",
"today": "сегодня",
"yesterday": "вчера",
"sendError": "Не удалось отправить",
"retry": "Повторить",
"imageUploadError": "Не удалось загрузить изображение",
"attachImage": "Прикрепить фото",
"messageTooLong": "Сообщение слишком длинное (макс. 2000 символов)"
}
}Error Handling
| HTTP | Code | When | Frontend Action |
|---|---|---|---|
| 401 | authentication_error | Session expired | Redirect to /auth |
| 403 | authorization_error | Not a participant | Show "Access denied", navigate back |
| 404 | not_found_error | Conversation/product deleted | Show "Not found", navigate to list |
| 422 | validation_error | Bad input | Show field-level errors from details |
| 429 | rate_limit_error | Too many requests | Show "Try again later" |
Cabinet Layout Integration
Add "Messages" link with unread badge to the cabinet sidebar:
html
<!-- layouts/cabinet.vue -->
<UButton
label="Сообщения"
to="/cabinet/messages"
variant="ghost"
block
>
<template #trailing>
<ChatUnreadBadge />
</template>
</UButton>The ChatUnreadBadge widget should:
- Fetch initial count via
GET /api/vendor/conversations/unread-counton mount - Listen for
unread.updateevents via Centrifugo to stay reactive - Listen for
message.newevents to increment count when not in the active conversation
Infrastructure Notes
- Centrifugo version: 5.4.9 (Docker)
- Dev container:
partizap-centrifugo-dev, ports 8000 (WS) / 8001 (API) - Nginx proxies
wss://dev.partizap.ru/connection/websocketto Centrifugo port 8000 - No tokens needed — auth is cookie-based via connect proxy
- centrifuge-js: Use
centrifugenpm package (official client)