Appearance
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 | null | user.phones: UserPhone[] |
PUT /vendor/me принимал phone | phone убран, управление через /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.details→fieldErrors - 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 на оба сценария. Поля:
- Телефон —
UInputс маской+7 (___) ___-__-__, inputmode="tel". Префикс+7фиксирован. Перед отправкой —unmaskPhone()→ E.164 - Метка —
USelectMenuс предустановленными значениями + «Другое» →UInputдля кастомной метки - Основной —
USwitch. Для первого телефона отключён и включён принудительно
Футер: «Отмена» + «Сохранить» (с loading).
Модалка удаления
Отдельная UModal: «Удалить номер +7 (921) 987-65-43?». Кнопка «Удалить» красная с loading.
Жизненный цикл
onMounted → loadPhones(). После каждого add/update/delete — loadPhones().
Toast-уведомления: «Телефон сохранён» (success), «Телефон удалён» (success).
4. Изменения в профиле (profile.vue)
Убираем
phoneDisplay,phoneErrorrefsonPhoneInput(),validatePhone()функции- Инициализация
phoneDisplayвonMounted hasValidationErrorscomputedUFormFieldс phone input в шаблонеimport { formatPhone, unmaskPhone, isValidRussianPhone }(переехало в PhoneManager)
Убираем из useProfileForm
form.phoneиз reactiveform.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
- form reactive — добавить
phone_id: null as number | null - loadProduct() —
form.phone_id = p.phone_id ?? null - 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, проверяем заполнениеphonesaddPhone— мокаем POST + GET, проверяем возвратtrueaddPhoneс ошибкой 422 — проверяемfieldErrorsdeletePhone— мокаем 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 сортировка телефонов
- Показ телефонов в админке