Skip to content

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

BeforeAfter
user.phone (string)user.phones[] (array of UserPhone objects)
PUT /vendor/me accepted phone fieldphone removed from profile update
No phone on productsproduct.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 NULL

UserPhone 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"
}
FieldTypeNotes
idnumberSERIAL PK
user_idnumberOwner
phonestringFormat: +7XXXXXXXXXX (Russian mobile)
labelstringUser-defined label, max 50 chars, default "Основной"
is_primarybooleanExactly one phone per user is primary
sort_ordernumberDisplay order (0-based, ascending)
phone_verifiedbooleanWhether phone is verified (future feature)
created_atstringISO 8601

API Endpoints

Base: /api/vendor/me/phones (all require authentication)

1. List Phones

GET /api/vendor/me/phones

Response 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/phones

Request:

json
{ "phone": "+79991234567", "label": "Рабочий", "is_primary": false }
FieldRequiredTypeValidation
phoneyesstringMust match /^\+7\d{10}$/
labelnostringMax 50 chars, default "Основной"
is_primarynobooleanDefault 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: true clears primary on all other phones
  • sort_order auto-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_id set to NULL (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 link

Product 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_id is 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.phone values were migrated to user_phones with label = "Основной", is_primary = true
  • Old phone and phone_verified_at columns dropped from users
  • 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)