Skip to content

Multi-Phone Support — Frontend Design

Дата: 2026-02-25 Статус: Approved Backend guide: docs/plans/later/2026-02-25-multi-phone-frontend-guide.md

Цель

Заменить одиночное поле user.phone на управление до 5 телефонов с метками. Добавить выбор телефона при создании/редактировании объявления. Публичный показ телефона покупателю откладываем.

Breaking Changes

ДоПосле
user.phone: string | nulluser.phones: UserPhone[]
PUT /vendor/me принимал phonephone убран, управление через /vendor/me/phones
Нет телефона на товарахproduct.phone_id: number | null

1. Модель данных и схемы

UserPhone (новая Zod-схема в entities/user/model/user.schema.ts)

ts
export const userPhoneSchema = z.object({
  id: z.number().int().positive(),
  user_id: z.number().int().positive(),
  phone: z.string(),           // E.164: "+79219876543"
  label: z.string(),           // "Основной", "Рабочий", etc.
  is_primary: z.boolean(),
  sort_order: z.number().int(),
  phone_verified: z.boolean(), // false пока нет SMS-верификации
  created_at: z.string(),
})

export type UserPhone = z.infer<typeof userPhoneSchema>

Изменения в userSchema

Строка 9 phone: z.string().nullable() заменяется на:

ts
phones: z.array(userPhoneSchema).default([]),

Изменения в product schemas

ts
// productDetailSchema — добавить:
phone_id: z.number().nullable()

// productFormSchema — добавить:
phone_id: z.number().nullable().default(null)

Убираем phone из settings schema

В shared/schemas/settings.ts удаляем поле phone (с refine-валидацией) из profileFormSchema. Телефоны управляются отдельным CRUD.

Phone utilities

Файл shared/lib/phone.ts уже содержит formatPhone, unmaskPhone, isValidRussianPhone — переиспользуем без изменений.


2. Composable usePhoneManager

Новый файл: features/cabinet-settings/composables/usePhoneManager.ts

Зона ответственности: CRUD телефонов через /vendor/me/phones. Не трогает auth store.

API-вызовы

МетодЭндпоинтОписание
loadPhones()GET /vendor/me/phonesЗагрузить список, отсортирован по sort_order
addPhone(phone, label, isPrimary)POST /vendor/me/phonesСоздать. Первый телефон всегда primary
updatePhone(id, data)PUT /vendor/me/phones/{id}Обновить номер/label/primary
deletePhone(id)DELETE /vendor/me/phones/{id}Удалить. Primary промоутится автоматически на бэке

Состояние

ts
phones: Ref<UserPhone[]>                  // readonly наружу
saving: Ref<boolean>                       // блокировка кнопки save
deleting: Ref<number | null>               // id удаляемого (для loading на конкретной строке)
error: Ref<string | null>                  // общая ошибка
fieldErrors: Ref<Record<string, string>>   // поле → первая ошибка

Computed-геттеры

ts
primaryPhone  // phones.find(p => p.is_primary) ?? null
canAdd        // phones.length < 5

Обработка ошибок

  • 422 с details — парсим error.detailsfieldErrors
  • 422 лимит"Maximum 5 phones allowed per user"error
  • 500 дубликат — DB unique constraint → показываем «Этот номер уже добавлен» в error
  • 404 — телефон не найден → перезагружаем список через loadPhones()

Предустановленные метки

ts
export const PHONE_LABELS = ['Основной', 'Рабочий', 'WhatsApp', 'Telegram'] as const

Плюс вариант «Другое» с кастомным текстовым полем (max 50 символов).


3. UI компонент PhoneManager.vue

Новый файл: features/cabinet-settings/ui/PhoneManager.vue

Встраивается в pages/cabinet/settings/profile.vue вместо текущего одиночного phone input.

Список телефонов

Карточки в столбик:

  • Каждая строка: номер +7 (921) 987-65-43, label мелким текстом, бейдж «Основной» если primary
  • Справа: кнопки edit (карандаш) и delete (корзина)
  • Кнопка «Добавить номер» сверху. Если 5 телефонов — текст «Максимум 5 номеров»

Пустое состояние

Dashed-border блок: «Нет номеров телефона» + ссылка «Добавить номер».

Модалка добавления/редактирования

Одна UModal на оба сценария. Поля:

  1. ТелефонUInput с маской +7 (___) ___-__-__, inputmode="tel". Префикс +7 фиксирован. Перед отправкой — unmaskPhone() → E.164
  2. МеткаUSelectMenu с предустановленными значениями + «Другое» → UInput для кастомной метки
  3. ОсновнойUSwitch. Для первого телефона отключён и включён принудительно

Футер: «Отмена» + «Сохранить» (с loading).

Модалка удаления

Отдельная UModal: «Удалить номер +7 (921) 987-65-43?». Кнопка «Удалить» красная с loading.

Жизненный цикл

onMounted → loadPhones(). После каждого add/update/delete — loadPhones().

Toast-уведомления: «Телефон сохранён» (success), «Телефон удалён» (success).


4. Изменения в профиле (profile.vue)

Убираем

  • phoneDisplay, phoneError refs
  • onPhoneInput(), validatePhone() функции
  • Инициализация phoneDisplay в onMounted
  • hasValidationErrors computed
  • UFormField с phone input в шаблоне
  • import { formatPhone, unmaskPhone, isValidRussianPhone } (переехало в PhoneManager)

Убираем из useProfileForm

  • form.phone из reactive
  • form.phone = u.phone из loadProfile()
  • phone: form.phone из saveProfile() body

Добавляем

html
<CabinetSettingsPhoneManager />

Авто-импорт по FSD-конвенции.

Упрощение handleSave

Убираем if (!validatePhone()) return. Убираем disabled по hasValidationErrors. Кнопка «Сохранить» сохраняет только display_name, account_type, geo.


5. Phone selector в форме товара

useProductForm.ts

  1. form reactive — добавить phone_id: null as number | null
  2. loadProduct()form.phone_id = p.phone_id ?? null
  3. buildRequestBody()phone_id: form.phone_id

ProductFormPage.vue

Секция «Телефон для связи» перед блоком Location:

ts
const phoneOptions = computed(() => {
  const phones = authStore.user?.phones ?? []
  return phones.map(p => ({
    label: `${formatPhone(p.phone)} · ${p.label}`,
    value: p.id,
  }))
})

Авто-выбор primary при создании:

ts
watch(() => authStore.user?.phones, (phones) => {
  if (form.phone_id === null && phones?.length) {
    const primary = phones.find(p => p.is_primary) ?? phones[0]
    form.phone_id = primary.id
  }
}, { immediate: true })
  • Если есть телефоны — USelectMenu
  • Если нет — текст + ссылка на /cabinet/settings/profile
  • Поле опциональное (phone_id = null допустим)

6. i18n ключи

Секция "settings"

json
"phones": "Телефоны",
"phonesEmpty": "Нет номеров телефона",
"addPhone": "Добавить номер",
"editPhone": "Редактирование",
"deletePhone": "Удалить номер",
"deletePhoneConfirm": "Удалить номер {phone}?",
"phoneLabel": "Название",
"phoneLabelPlaceholder": "Например: Рабочий",
"phonePrimary": "Основной",
"phoneMakePrimary": "Сделать основным",
"phoneMaxReached": "Максимум 5 номеров",
"phoneSaved": "Телефон сохранён",
"phoneDeleted": "Телефон удалён",
"phoneInvalid": "Введите номер в формате +7 (XXX) XXX-XX-XX",
"phoneLabelOther": "Другое"

Секция "listing"

json
"contactPhone": "Телефон для связи",
"selectPhone": "Выберите телефон",
"noPhones": "Нет номеров",
"addPhoneInSettings": "Добавьте в настройках профиля"

7. Тесты

Существующие

shared/lib/phone.test.ts — проверить что проходят после изменений схем.

Новые

features/cabinet-settings/composables/usePhoneManager.test.ts:

  • loadPhones — мокаем GET, проверяем заполнение phones
  • addPhone — мокаем POST + GET, проверяем возврат true
  • addPhone с ошибкой 422 — проверяем fieldErrors
  • deletePhone — мокаем DELETE + GET, проверяем вызов

Используем registerEndpoint из @nuxt/test-utils/runtime.


8. Порядок реализации

Task 1: Схемы (UserPhone, убрать phone из User, phone_id в Product)

Task 2: usePhoneManager composable + тесты

Task 3: PhoneManager.vue + интеграция в profile.vue + убрать старый phone

Task 4: phone_id в useProductForm + ProductFormPage.vue

Task 5: grep на оставшиеся user.phone / form.phone → починить

Task 6: i18n ключи (можно параллельно с Task 3-4, но проще одним коммитом)

Task 7: CLAUDE.md — обновить схемы, эндпоинты, структуру, прогресс

Task 1 → Task 2 → Task 3 строго последовательны. Task 4 зависит от Task 1, но не от 2-3.


Что НЕ делаем

  • Публичный показ телефона на product/:id (ждём подменные номера)
  • SMS-верификация (поле phone_verified заложено)
  • Drag-n-drop сортировка телефонов
  • Показ телефонов в админке