Appearance
Multi-Phone Support — Frontend Developer Guide
The single phone field on users has been replaced with a user_phones table supporting up to 5 phone numbers per user. Each phone has a label, primary flag, and sort order. Products can optionally link to a specific phone via phone_id. This guide covers the data model, endpoints, response shapes, TypeScript types, Zod schemas, and implementation patterns.
Breaking Changes
| Before | After |
|---|---|
user.phone (string) | user.phones[] (array of UserPhone objects) |
PUT /vendor/me accepted phone field | phone removed from profile update |
| No phone on products | product.phone_id (nullable int) |
Data Model
users
└── user_phones (1:N, max 5) — FK: user_id, ON DELETE CASCADE
└── products (1:N, optional) — FK: phone_id, ON DELETE SET NULLUserPhone Object
json
{
"id": 1,
"user_id": 20,
"phone": "+79991234567",
"label": "Основной",
"is_primary": true,
"sort_order": 0,
"phone_verified": false,
"created_at": "2026-02-25T13:38:25+03:00"
}| Field | Type | Notes |
|---|---|---|
id | number | SERIAL PK |
user_id | number | Owner |
phone | string | Format: +7XXXXXXXXXX (Russian mobile) |
label | string | User-defined label, max 50 chars, default "Основной" |
is_primary | boolean | Exactly one phone per user is primary |
sort_order | number | Display order (0-based, ascending) |
phone_verified | boolean | Whether phone is verified (future feature) |
created_at | string | ISO 8601 |
API Endpoints
Base: /api/vendor/me/phones (all require authentication)
1. List Phones
GET /api/vendor/me/phonesResponse 200:
json
{
"data": [
{ "id": 1, "user_id": 20, "phone": "+79991234567", "label": "Основной", "is_primary": true, "sort_order": 0, "phone_verified": false, "created_at": "..." },
{ "id": 2, "user_id": 20, "phone": "+79997654321", "label": "Рабочий", "is_primary": false, "sort_order": 1, "phone_verified": false, "created_at": "..." }
]
}Sorted by sort_order ascending.
2. Create Phone
POST /api/vendor/me/phonesRequest:
json
{ "phone": "+79991234567", "label": "Рабочий", "is_primary": false }| Field | Required | Type | Validation |
|---|---|---|---|
phone | yes | string | Must match /^\+7\d{10}$/ |
label | no | string | Max 50 chars, default "Основной" |
is_primary | no | boolean | Default false |
Response 201: UserPhone object.
Business rules:
- Max 5 phones per user → 422
"Maximum 5 phones allowed per user" - Duplicate phone for same user → 500 (DB unique constraint)
- First phone is always forced
is_primary = true - Setting
is_primary: trueclears primary on all other phones sort_orderauto-assigned (max existing + 1)
3. Update Phone
PUT /api/vendor/me/phones/{id}Request (all fields optional):
json
{ "phone": "+79998887766", "label": "Личный", "is_primary": true, "sort_order": 0 }Same validation as create. Setting is_primary: true clears primary on others.
Response 200: Updated UserPhone object.
Errors:
- 404 if phone doesn't exist or belongs to another user
4. Delete Phone
DELETE /api/vendor/me/phones/{id}Response 204 (empty body).
Side effects:
- Products linked to deleted phone →
phone_idset toNULL(DB cascade) - If deleted phone was primary → first remaining phone promoted to primary
Errors:
- 404 if phone doesn't exist or belongs to another user
Phones in Profile Responses
GET /auth/me and GET /vendor/me now include a phones array:
json
{
"data": {
"id": 20,
"email": "user@example.com",
"display_name": "John Doe",
"account_type": "personal",
"email_verified": true,
"phones": [
{ "id": 1, "user_id": 20, "phone": "+79991234567", "label": "Основной", "is_primary": true, "sort_order": 0, "phone_verified": false, "created_at": "..." }
],
...
}
}PUT /vendor/me no longer accepts a phone field. Manage phones via /vendor/me/phones.
Phone on Products
Products have an optional phone_id field linking to one of the seller's phones.
Create product:
json
POST /api/vendor/products
{ "title": "...", "price": 4500, "phone_id": 1 }Update product:
json
PUT /api/vendor/products/{id}
{ "phone_id": 2 } // link to different phone
{ "phone_id": null } // clear phone linkProduct response includes phone_id:
json
{
"data": {
"id": 1,
"seller_id": 20,
"title": "...",
"phone_id": 1,
...
}
}To display the phone number on a product page, look up phone_id in the seller's phones array (from /vendor/me/phones or the phones[] in profile response).
TypeScript Types
ts
interface UserPhone {
id: number
user_id: number
phone: string
label: string
is_primary: boolean
sort_order: number
phone_verified: boolean
created_at: string
}
// Updated User type — phones replaces phone
interface User {
id: number
email: string
display_name: string
account_type: 'personal' | 'business'
email_verified: boolean
avatar_url: string | null
city_id: number | null
district_id: number | null
metro_station_id: number | null
rating: number
reviews_count: number
products_count: number
is_active: boolean
is_admin: boolean
created_at: string
phones: UserPhone[] // NEW — replaces old `phone: string | null`
}
// Product now includes phone_id
interface Product {
// ... existing fields ...
phone_id: number | null // NEW
}Zod Schemas
ts
import { z } from 'zod'
// Phone validation
const phoneRegex = /^\+7\d{10}$/
export const userPhoneSchema = z.object({
id: z.number(),
user_id: z.number(),
phone: z.string(),
label: z.string(),
is_primary: z.boolean(),
sort_order: z.number(),
phone_verified: z.boolean(),
created_at: z.string(),
})
export type UserPhone = z.infer<typeof userPhoneSchema>
// Create phone form
export const createPhoneSchema = z.object({
phone: z.string().regex(phoneRegex, 'Формат: +7XXXXXXXXXX'),
label: z.string().max(50).optional().default('Основной'),
is_primary: z.boolean().optional().default(false),
})
// Update phone form
export const updatePhoneSchema = z.object({
phone: z.string().regex(phoneRegex, 'Формат: +7XXXXXXXXXX').optional(),
label: z.string().max(50).optional(),
is_primary: z.boolean().optional(),
sort_order: z.number().int().min(0).optional(),
})Composable Pattern
ts
// composables/usePhones.ts
export function usePhones() {
const phones = ref<UserPhone[]>([])
const loading = ref(false)
async function fetchPhones() {
loading.value = true
try {
const { data } = await useApiFetch<{ data: UserPhone[] }>('/vendor/me/phones')
phones.value = data
} finally {
loading.value = false
}
}
async function createPhone(body: z.infer<typeof createPhoneSchema>) {
const { data } = await useApiFetch<{ data: UserPhone }>('/vendor/me/phones', {
method: 'POST',
body,
})
phones.value.push(data)
// If new phone is primary, update others locally
if (data.is_primary) {
phones.value.forEach(p => {
if (p.id !== data.id) p.is_primary = false
})
}
return data
}
async function updatePhone(id: number, body: z.infer<typeof updatePhoneSchema>) {
const { data } = await useApiFetch<{ data: UserPhone }>(`/vendor/me/phones/${id}`, {
method: 'PUT',
body,
})
const idx = phones.value.findIndex(p => p.id === id)
if (idx !== -1) phones.value[idx] = data
if (data.is_primary) {
phones.value.forEach(p => {
if (p.id !== data.id) p.is_primary = false
})
}
return data
}
async function deletePhone(id: number) {
await useApiFetch(`/vendor/me/phones/${id}`, { method: 'DELETE' })
phones.value = phones.value.filter(p => p.id !== id)
// If we deleted the primary and others remain, refetch to get updated primary
if (phones.value.length > 0 && !phones.value.some(p => p.is_primary)) {
await fetchPhones()
}
}
const primaryPhone = computed(() => phones.value.find(p => p.is_primary) ?? null)
const canAddMore = computed(() => phones.value.length < 5)
return { phones, loading, primaryPhone, canAddMore, fetchPhones, createPhone, updatePhone, deletePhone }
}UI Recommendations
Phone List in Cabinet
- Show phones sorted by
sort_order - Primary phone gets a badge/tag (e.g. "Основной")
- Each phone row: phone number, label, edit/delete buttons
- "Add phone" button disabled when
phones.length >= 5 - Confirm dialog before delete (mention products will lose phone link)
Phone Input Mask
Use an input mask for +7 (___) ___-__-__ format. Store as +7XXXXXXXXXX (no spaces/dashes).
Phone Selector on Product Form
- Dropdown/select with user's phones:
"label — phone"format - Optional — can be left empty
- Pre-select primary phone when creating a new product
Product Detail Page (Public)
- If
phone_idis set, resolve the phone number from the seller's phones - Display with a "Call" / "Copy" button
- For non-authenticated viewers, consider masking part of the number until click
Migration Notes
- Existing
users.phonevalues were migrated touser_phoneswithlabel = "Основной",is_primary = true - Old
phoneandphone_verified_atcolumns dropped fromusers - Existing products have
phone_id = NULL(not backfilled)
Live Examples (dev.partizap.ru)
Tested 2026-02-25, all passing:
POST /api/vendor/me/phones {"phone":"+71234567890"} → 201
GET /api/vendor/me/phones → 200 (1 phone, is_primary=true)
PUT /api/vendor/me/phones/2 {"label":"Рабочий"} → 200 (label updated)
GET /api/auth/me → 200 (phones[] in response)
POST /api/vendor/me/phones {"phone":"+70987654321","is_primary":true} → 201 (old phone lost primary)
DELETE /api/vendor/me/phones/3 → 204 (remaining phone promoted)