Skip to content

Multi-Phone Support — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace single user.phone field with multi-phone management (up to 5 phones), add phone selector to product form.

Architecture: New userPhoneSchema in entities/user, new usePhoneManager composable in features/cabinet-settings for CRUD via /vendor/me/phones, phone selector in product form reads phones from auth store. Remove single phone from profile form.

Tech Stack: Zod (validation), Pinia (auth store), Nuxt UI v3 (USelectMenu, UModal, UInput, USwitch, UBadge), existing shared/lib/phone.ts utilities.


Task 1: Update schemas — UserPhone + remove single phone

Files:

  • Modify: app/entities/user/model/user.schema.ts:1-37
  • Modify: app/shared/schemas/settings.ts:3-16
  • Modify: app/entities/product/model/product.schema.ts:82-96,120-138

Step 1: Add userPhoneSchema and update userSchema

In app/entities/user/model/user.schema.ts, replace the entire file with:

ts
import { z } from 'zod'

export const accountTypeSchema = z.enum(['personal', 'business'])

export const userPhoneSchema = z.object({
  id: z.number().int().positive(),
  user_id: z.number().int().positive(),
  phone: z.string(),
  label: z.string(),
  is_primary: z.boolean(),
  sort_order: z.number().int(),
  phone_verified: z.boolean(),
  created_at: z.string(),
})

export const userSchema = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  display_name: z.string(),
  phones: z.array(userPhoneSchema).default([]),
  account_type: accountTypeSchema,
  email_verified: z.boolean(),
  avatar_url: z.string().nullable(),
  city_id: z.number().nullable(),
  district_id: z.number().nullable(),
  metro_station_id: z.number().nullable(),
  rating: z.string(),
  reviews_count: z.number().int(),
  products_count: z.number().int(),
  is_active: z.boolean(),
  is_admin: z.boolean(),
  created_at: z.string(),
})

export const businessProfileSchema = z.object({
  id: z.number().int().positive(),
  company_name: z.string(),
  inn: z.string().nullable(),
  address: z.string().nullable(),
  website: z.string().nullable(),
  working_hours: z.string().nullable(),
  is_verified: z.boolean(),
})

export type User = z.infer<typeof userSchema>
export type UserPhone = z.infer<typeof userPhoneSchema>
export type AccountType = z.infer<typeof accountTypeSchema>
export type BusinessProfile = z.infer<typeof businessProfileSchema>

Step 2: Remove phone from profileFormSchema

In app/shared/schemas/settings.ts, replace lines 3-16 with:

ts
export const profileFormSchema = z.object({
  display_name: z.string().min(1, 'Введите имя').max(100, 'Максимум 100 символов'),
  account_type: z.enum(['personal', 'business']),
  city_id: z.number().nullable(),
  district_id: z.number().nullable(),
  metro_station_id: z.number().nullable(),
})

Step 3: Add phone_id to product schemas

In app/entities/product/model/product.schema.ts, add phone_id to productDetailSchema — replace lines 82-96 with:

ts
export const productDetailSchema = productSchema.extend({
  seller_id: z.number(),
  city_id: z.number(),
  region_id: z.number().nullable(),
  district_id: z.number().nullable(),
  metro_station_id: z.number().nullable(),
  address: z.string().nullable(),
  phone_id: z.number().nullable(),
  primary_category_id: z.number().nullable(),
  is_available: z.boolean(),
  images: z.array(productImageSchema),
  compatibility: z.array(compatibilitySchema),
  oem_numbers: z.array(productOemSchema),
  categories: z.array(productCategorySchema),
  seller: sellerPublicSchema,
})

Add phone_id to productFormSchema — replace lines 120-138 with:

ts
export const productFormSchema = z.object({
  title: titleField.default(PRODUCT_DEFAULT_TITLE),
  description: descriptionField,
  price: priceField.default(PRODUCT_DEFAULT_PRICE),
  steering: steeringSchema.default('universal'),
  oem_number: oemNumberField,
  manufacturer: manufacturerField,
  region_id: z.number().positive().optional(),
  city_id: z.number().nonnegative().default(0),
  district_id: z.number().positive().optional(),
  metro_station_id: z.number().positive().optional(),
  address: addressField,
  phone_id: z.number().nullable().default(null),
  category_ids: z.array(productCategorySchema),
  condition_category_id: z.number().nonnegative().default(0),
  attribute_category_id: z.number().nonnegative().default(0),
  compatibility: z.array(compatibilitySchema).default([]),
  is_kit: z.boolean().default(false),
  oem_numbers: oemNumbersField,
})

Step 4: Run existing phone tests to verify they still pass

Run: npx vitest run app/shared/lib/phone.test.ts Expected: PASS (11 tests — these test utilities, not schemas)

Step 5: Commit

bash
git add app/entities/user/model/user.schema.ts app/shared/schemas/settings.ts app/entities/product/model/product.schema.ts
git commit -m "feat(phone): add UserPhone schema, remove single phone field, add phone_id to product"

Task 2: Remove phone from profile form composable + fix test

Files:

  • Modify: app/features/cabinet-settings/composables/useProfileForm.ts:11-18,28-29,42-48
  • Modify: app/features/cabinet-settings/composables/useProfileForm.test.ts:36-56

Step 1: Update useProfileForm — remove phone from form, loadProfile, saveProfile

In app/features/cabinet-settings/composables/useProfileForm.ts:

Replace lines 11-18 (form reactive) with:

ts
const form = reactive<ProfileForm>({
  display_name: '',
  account_type: 'personal',
  city_id: null,
  district_id: null,
  metro_station_id: null,
})

Replace line 29 (form.phone = u.phone) — remove it entirely. The loadProfile function (lines 25-34) should become:

ts
async function loadProfile() {
  const response = await api.get<ApiItemResponse<User>>('/vendor/me')
  const u = response.data
  form.display_name = u.display_name
  form.account_type = u.account_type
  form.city_id = u.city_id
  form.district_id = u.district_id
  form.metro_station_id = u.metro_station_id
}

Replace lines 42-48 (saveProfile body) with:

ts
const response = await api.put<ApiItemResponse<User>>('/vendor/me', {
  display_name: form.display_name,
  account_type: form.account_type,
  city_id: form.city_id,
  district_id: form.district_id,
  metro_station_id: form.metro_station_id,
})

Step 2: Update test — remove phone assertions

In app/features/cabinet-settings/composables/useProfileForm.test.ts, replace lines 36-56 with:

ts
it('loadProfile populates form from API', async () => {
  mockGet.mockResolvedValue({
    data: {
      id: 1,
      display_name: 'Иван',
      phones: [
        {
          id: 1,
          user_id: 1,
          phone: '+79001234567',
          label: 'Основной',
          is_primary: true,
          sort_order: 0,
          phone_verified: false,
          created_at: '2026-01-01T00:00:00Z',
        },
      ],
      account_type: 'personal',
      city_id: 1,
      district_id: null,
      metro_station_id: null,
    },
  })

  const { form, loadProfile } = useProfileForm()
  await loadProfile()

  expect(mockGet).toHaveBeenCalledWith('/vendor/me')
  expect(form.display_name).toBe('Иван')
  expect(form.account_type).toBe('personal')
})

Step 3: Run test to verify it passes

Run: npx vitest run app/features/cabinet-settings/composables/useProfileForm.test.ts Expected: PASS (3 tests)

Step 4: Commit

bash
git add app/features/cabinet-settings/composables/useProfileForm.ts app/features/cabinet-settings/composables/useProfileForm.test.ts
git commit -m "refactor(phone): remove single phone from profile form composable"

Task 3: Create usePhoneManager composable

Files:

  • Create: app/features/cabinet-settings/composables/usePhoneManager.ts

Step 1: Write failing test

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

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
  post: mockPost,
  put: mockPut,
  delete: mockDelete,
}))

const { usePhoneManager } = await import('./usePhoneManager')

const PHONE_FIXTURE = {
  id: 1,
  user_id: 20,
  phone: '+79219876543',
  label: 'Основной',
  is_primary: true,
  sort_order: 0,
  phone_verified: false,
  created_at: '2026-01-01T00:00:00Z',
}

describe('usePhoneManager', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('loadPhones fetches and populates phones list', async () => {
    mockGet.mockResolvedValue({ data: [PHONE_FIXTURE] })

    const manager = usePhoneManager()
    await manager.loadPhones()

    expect(mockGet).toHaveBeenCalledWith('/vendor/me/phones')
    expect(manager.phones.value).toHaveLength(1)
    expect(manager.phones.value[0].phone).toBe('+79219876543')
  })

  it('primaryPhone returns the primary phone', async () => {
    mockGet.mockResolvedValue({
      data: [PHONE_FIXTURE, { ...PHONE_FIXTURE, id: 2, is_primary: false }],
    })

    const manager = usePhoneManager()
    await manager.loadPhones()

    expect(manager.primaryPhone.value?.id).toBe(1)
  })

  it('canAdd returns true when under limit', async () => {
    mockGet.mockResolvedValue({ data: [PHONE_FIXTURE] })

    const manager = usePhoneManager()
    await manager.loadPhones()

    expect(manager.canAdd.value).toBe(true)
  })

  it('canAdd returns false at 5 phones', async () => {
    const fivePhones = Array.from({ length: 5 }, (_, i) => ({ ...PHONE_FIXTURE, id: i + 1 }))
    mockGet.mockResolvedValue({ data: fivePhones })

    const manager = usePhoneManager()
    await manager.loadPhones()

    expect(manager.canAdd.value).toBe(false)
  })

  it('addPhone posts and reloads', async () => {
    mockPost.mockResolvedValue({ data: PHONE_FIXTURE })
    mockGet.mockResolvedValue({ data: [PHONE_FIXTURE] })

    const manager = usePhoneManager()
    const result = await manager.addPhone('+79219876543', 'Основной')

    expect(result).toBe(true)
    expect(mockPost).toHaveBeenCalledWith('/vendor/me/phones', {
      phone: '+79219876543',
      label: 'Основной',
      is_primary: false,
    })
    expect(mockGet).toHaveBeenCalledWith('/vendor/me/phones')
  })

  it('addPhone returns false and sets fieldErrors on 422', async () => {
    mockPost.mockRejectedValue({
      data: { error: { details: { phone: ['Этот номер уже добавлен'] } } },
    })

    const manager = usePhoneManager()
    const result = await manager.addPhone('+79219876543', 'Основной')

    expect(result).toBe(false)
    expect(manager.fieldErrors.value.phone).toBe('Этот номер уже добавлен')
  })

  it('deletePhone calls DELETE and reloads', async () => {
    mockDelete.mockResolvedValue(undefined)
    mockGet.mockResolvedValue({ data: [] })

    const manager = usePhoneManager()
    const result = await manager.deletePhone(1)

    expect(result).toBe(true)
    expect(mockDelete).toHaveBeenCalledWith('/vendor/me/phones/1')
    expect(mockGet).toHaveBeenCalledWith('/vendor/me/phones')
  })

  it('updatePhone calls PUT and reloads', async () => {
    mockPut.mockResolvedValue({ data: { ...PHONE_FIXTURE, label: 'Рабочий' } })
    mockGet.mockResolvedValue({ data: [{ ...PHONE_FIXTURE, label: 'Рабочий' }] })

    const manager = usePhoneManager()
    const result = await manager.updatePhone(1, { label: 'Рабочий' })

    expect(result).toBe(true)
    expect(mockPut).toHaveBeenCalledWith('/vendor/me/phones/1', { label: 'Рабочий' })
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/cabinet-settings/composables/usePhoneManager.test.ts Expected: FAIL — module ./usePhoneManager not found

Step 3: Implement usePhoneManager

Create app/features/cabinet-settings/composables/usePhoneManager.ts:

ts
import { ref, computed, readonly } from 'vue'

import type { UserPhone } from '~/entities/user/model/user.schema'
import type { ApiItemResponse } from '~/shared/api/types'

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

const MAX_PHONES = 5

export function usePhoneManager() {
  const api = useApi()

  const phones = ref<UserPhone[]>([])
  const saving = ref(false)
  const deleting = ref<number | null>(null)
  const error = ref<string | null>(null)
  const fieldErrors = ref<Record<string, string>>({})

  const primaryPhone = computed(() => phones.value.find((p) => p.is_primary) ?? null)
  const canAdd = computed(() => phones.value.length < MAX_PHONES)

  async function loadPhones() {
    const response = await api.get<{ data: UserPhone[] }>('/vendor/me/phones')
    phones.value = response.data
  }

  async function addPhone(phone: string, label: string, isPrimary = false): Promise<boolean> {
    saving.value = true
    error.value = null
    fieldErrors.value = {}

    try {
      await api.post<ApiItemResponse<UserPhone>>('/vendor/me/phones', {
        phone,
        label,
        is_primary: isPrimary,
      })
      await loadPhones()
      return true
    } catch (e: unknown) {
      handleApiError(e)
      return false
    } finally {
      saving.value = false
    }
  }

  async function updatePhone(
    id: number,
    data: { phone?: string; label?: string; is_primary?: boolean },
  ): Promise<boolean> {
    saving.value = true
    error.value = null
    fieldErrors.value = {}

    try {
      await api.put<ApiItemResponse<UserPhone>>(`/vendor/me/phones/${id}`, data)
      await loadPhones()
      return true
    } catch (e: unknown) {
      handleApiError(e)
      return false
    } finally {
      saving.value = false
    }
  }

  async function deletePhone(id: number): Promise<boolean> {
    deleting.value = id
    error.value = null

    try {
      await api.delete(`/vendor/me/phones/${id}`)
      await loadPhones()
      return true
    } catch (e: unknown) {
      handleApiError(e)
      return false
    } finally {
      deleting.value = null
    }
  }

  function handleApiError(e: unknown) {
    const fetchError = e as {
      data?: { error?: { message?: string; details?: Record<string, string[]> } }
    }
    const apiError = fetchError?.data?.error
    if (apiError?.details) {
      for (const [field, messages] of Object.entries(apiError.details)) {
        fieldErrors.value[field] = messages[0] ?? ''
      }
    } else {
      error.value = apiError?.message ?? 'Ошибка сохранения'
    }
  }

  return {
    phones: readonly(phones),
    saving: readonly(saving),
    deleting: readonly(deleting),
    error: readonly(error),
    fieldErrors: readonly(fieldErrors),
    primaryPhone,
    canAdd,
    loadPhones,
    addPhone,
    updatePhone,
    deletePhone,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/cabinet-settings/composables/usePhoneManager.test.ts Expected: PASS (8 tests)

Step 5: Commit

bash
git add app/features/cabinet-settings/composables/usePhoneManager.ts app/features/cabinet-settings/composables/usePhoneManager.test.ts
git commit -m "feat(phone): add usePhoneManager composable with CRUD + tests"

Task 4: Add i18n keys

Files:

  • Modify: i18n/locales/ru.json:278,188

Step 1: Add phone management keys to settings section

In i18n/locales/ru.json, after line 278 ("phoneInvalid": "Введите номер в формате +7 (XXX) XXX-XX-XX",), add:

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

Step 2: Add phone selector keys to listing section

In i18n/locales/ru.json, after line 188 ("addressPlaceholder": "Адрес (необязательно)",), add:

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

Step 3: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(phone): add i18n keys for phone management and product form"

Task 5: Create PhoneManager.vue component

Files:

  • Create: app/features/cabinet-settings/ui/PhoneManager.vue

Step 1: Create the component

Create app/features/cabinet-settings/ui/PhoneManager.vue:

html
<script setup lang="ts">
import { ref, computed } from 'vue'

import type { UserPhone } from '~/entities/user/model/user.schema'

import { formatPhone, unmaskPhone, isValidRussianPhone } from '~/shared/lib/phone'

import { usePhoneManager, PHONE_LABELS } from '../composables/usePhoneManager'

const { t } = useI18n()
const toast = useToast()

const {
  phones,
  saving,
  deleting,
  error,
  fieldErrors,
  canAdd,
  loadPhones,
  addPhone,
  updatePhone,
  deletePhone,
} = usePhoneManager()

// Modal state
const modalOpen = ref(false)
const editingPhone = ref<UserPhone | null>(null)
const modalPhone = ref('')
const modalLabel = ref('Основной')
const modalCustomLabel = ref('')
const modalIsPrimary = ref(false)
const modalValidationError = ref<string | null>(null)

// Delete confirm
const deleteModalOpen = ref(false)
const deletingPhoneItem = ref<UserPhone | null>(null)

const labelOptions = computed(() => [
  ...PHONE_LABELS.map((l) => ({ label: l, value: l })),
  { label: t('settings.phoneLabelOther'), value: '__other__' },
])

function openAddModal() {
  editingPhone.value = null
  modalPhone.value = '+7 '
  modalLabel.value = 'Основной'
  modalCustomLabel.value = ''
  modalIsPrimary.value = phones.value.length === 0
  modalValidationError.value = null
  modalOpen.value = true
}

function openEditModal(phone: UserPhone) {
  editingPhone.value = phone
  modalPhone.value = formatPhone(phone.phone)
  if ((PHONE_LABELS as readonly string[]).includes(phone.label)) {
    modalLabel.value = phone.label
    modalCustomLabel.value = ''
  } else {
    modalLabel.value = '__other__'
    modalCustomLabel.value = phone.label
  }
  modalIsPrimary.value = phone.is_primary
  modalValidationError.value = null
  modalOpen.value = true
}

function confirmDelete(phone: UserPhone) {
  deletingPhoneItem.value = phone
  deleteModalOpen.value = true
}

const resolvedLabel = computed(() => {
  if (modalLabel.value === '__other__') return modalCustomLabel.value.trim() || 'Другое'
  return modalLabel.value
})

function onPhoneInput(value: string) {
  const digits = value.replace(/[^+\d]/g, '')
  if (!digits.startsWith('+7')) {
    modalPhone.value = '+7 '
    return
  }
  const limited = digits.slice(0, 12)
  modalPhone.value = formatPhone(limited)
  modalValidationError.value = null
}

async function handleSave() {
  const cleaned = unmaskPhone(modalPhone.value)

  if (!isValidRussianPhone(cleaned)) {
    modalValidationError.value = t('settings.phoneInvalid')
    return
  }

  const label = resolvedLabel.value

  let success: boolean
  if (editingPhone.value) {
    success = await updatePhone(editingPhone.value.id, {
      phone: cleaned,
      label,
      is_primary: modalIsPrimary.value,
    })
  } else {
    success = await addPhone(cleaned, label, modalIsPrimary.value)
  }

  if (success) {
    modalOpen.value = false
    toast.add({ title: t('settings.phoneSaved'), color: 'success' })
  }
}

async function handleDelete() {
  if (!deletingPhoneItem.value) return
  const success = await deletePhone(deletingPhoneItem.value.id)
  if (success) {
    deleteModalOpen.value = false
    deletingPhoneItem.value = null
    toast.add({ title: t('settings.phoneDeleted'), color: 'success' })
  }
}

onMounted(() => {
  loadPhones()
})
</script>

<template>
  <div>
    <div class="flex items-center justify-between mb-2">
      <label class="text-sm font-medium">&#123;&#123; t('settings.phones') }}</label>
      <UButton v-if="canAdd" variant="link" size="sm" icon="i-lucide-plus" @click="openAddModal">
        &#123;&#123; t('settings.addPhone') }}
      </UButton>
      <span v-else class="text-xs text-[var(--ui-text-muted)]">&#123;&#123;
        t('settings.phoneMaxReached')
      }}</span>
    </div>

    <!-- Empty state -->
    <div
      v-if="phones.length === 0"
      class="rounded-lg border border-dashed border-[var(--ui-border)] p-4 text-center text-sm text-[var(--ui-text-muted)]"
    >
      <p>&#123;&#123; t('settings.phonesEmpty') }}</p>
      <UButton variant="link" size="sm" class="mt-1" @click="openAddModal">
        &#123;&#123; t('settings.addPhone') }}
      </UButton>
    </div>

    <!-- Phone list -->
    <div v-else class="flex flex-col gap-2">
      <div
        v-for="phone in phones"
        :key="phone.id"
        class="flex items-center justify-between rounded-lg border border-[var(--ui-border)] px-3 py-2"
      >
        <div class="flex flex-col gap-0.5">
          <div class="flex items-center gap-2">
            <span class="font-medium">&#123;&#123; formatPhone(phone.phone) }}</span>
            <UBadge v-if="phone.is_primary" color="primary" variant="subtle" size="xs">
              &#123;&#123; t('settings.phonePrimary') }}
            </UBadge>
          </div>
          <span class="text-xs text-[var(--ui-text-muted)]">&#123;&#123; phone.label }}</span>
        </div>
        <div class="flex items-center gap-1">
          <UButton icon="i-lucide-pencil" variant="ghost" size="xs" @click="openEditModal(phone)" />
          <UButton
            icon="i-lucide-trash-2"
            variant="ghost"
            color="error"
            size="xs"
            :loading="deleting === phone.id"
            @click="confirmDelete(phone)"
          />
        </div>
      </div>
    </div>

    <UAlert v-if="error" color="error" :title="error" class="mt-2" />

    <!-- Add/Edit modal -->
    <UModal v-model:open="modalOpen">
      <template #header>
        &#123;&#123; editingPhone ? t('settings.editPhone') : t('settings.addPhone') }}
      </template>
      <template #body>
        <div class="flex flex-col gap-4">
          <UFormField
            :label="t('settings.phone')"
            :error="modalValidationError || fieldErrors.phone"
          >
            <UInput
              :model-value="modalPhone"
              placeholder="+7 (___) ___-__-__"
              class="w-full"
              inputmode="tel"
              @update:model-value="onPhoneInput"
            />
          </UFormField>

          <UFormField :label="t('settings.phoneLabel')">
            <USelectMenu
              v-model="modalLabel"
              :items="labelOptions"
              value-key="value"
              class="w-full"
            />
          </UFormField>

          <UFormField
            v-if="modalLabel === '__other__'"
            :label="t('settings.phoneLabelPlaceholder')"
          >
            <UInput
              v-model="modalCustomLabel"
              :placeholder="t('settings.phoneLabelPlaceholder')"
              :maxlength="50"
              class="w-full"
            />
          </UFormField>

          <div class="flex items-center gap-2">
            <USwitch v-model="modalIsPrimary" :disabled="phones.length === 0 && !editingPhone" />
            <span class="text-sm">&#123;&#123; t('settings.phoneMakePrimary') }}</span>
          </div>
        </div>
      </template>
      <template #footer>
        <div class="flex justify-end gap-3">
          <UButton variant="outline" @click="modalOpen = false">
            &#123;&#123; t('common.cancel') }}
          </UButton>
          <UButton color="primary" :loading="saving" @click="handleSave">
            &#123;&#123; t('common.save') }}
          </UButton>
        </div>
      </template>
    </UModal>

    <!-- Delete confirm modal -->
    <UModal v-model:open="deleteModalOpen">
      <template #header>
        &#123;&#123; t('settings.deletePhone') }}
      </template>
      <template #body>
        <p>
          &#123;&#123;
            t('settings.deletePhoneConfirm', {
              phone: deletingPhoneItem ? formatPhone(deletingPhoneItem.phone) : '',
            })
          }}
        </p>
      </template>
      <template #footer>
        <div class="flex justify-end gap-3">
          <UButton variant="outline" @click="deleteModalOpen = false">
            &#123;&#123; t('common.cancel') }}
          </UButton>
          <UButton color="error" :loading="deleting !== null" @click="handleDelete">
            &#123;&#123; t('settings.deletePhone') }}
          </UButton>
        </div>
      </template>
    </UModal>
  </div>
</template>

Step 2: Run lint to check for issues

Run: npm run lint:fix Expected: PASS (auto-import fixes if any)

Step 3: Commit

bash
git add app/features/cabinet-settings/ui/PhoneManager.vue
git commit -m "feat(phone): add PhoneManager component with add/edit/delete modals"

Task 6: Integrate PhoneManager into profile page

Files:

  • Modify: app/pages/cabinet/settings/profile.vue

Step 1: Rewrite profile.vue — remove old phone logic, add PhoneManager

Replace the entire file app/pages/cabinet/settings/profile.vue with:

html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Профиль', robots: 'noindex, nofollow' })

const { t } = useI18n()
const toast = useToast()
const { form, saving, uploading, error, fieldErrors, loadProfile, saveProfile, uploadAvatar } =
  useProfileForm()

const avatarPreview = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const authStore = useAuthStore()

onMounted(async () => {
  await loadProfile()
})

async function handleAvatarChange(event: Event) {
  const input = event.target as HTMLInputElement
  const file = input.files?.[0]
  if (!file) return

  avatarPreview.value = URL.createObjectURL(file)
  const success = await uploadAvatar(file)
  if (success) {
    toast.add({ title: t('settings.profileSaved'), color: 'success' })
  }
}

async function handleSave() {
  const success = await saveProfile()
  if (success) {
    toast.add({ title: t('settings.profileSaved'), color: 'success' })
  }
}
</script>

<template>
  <div>
    <h1 class="text-2xl font-bold mb-2">
      &#123;&#123; t('common.settings') }}
    </h1>
    <CabinetSettingsSettingsTabs />

    <div class="max-w-lg space-y-6">
      <!-- Global error -->
      <UAlert v-if="error" color="error" :title="error" />

      <!-- Avatar -->
      <div class="flex items-center gap-4">
        <div class="relative h-16 w-16 shrink-0">
          <img
            v-if="avatarPreview || authStore.user?.avatar_url"
            :src="avatarPreview || authStore.user?.avatar_url || ''"
            class="h-16 w-16 rounded-full object-cover"
          />
          <div
            v-else
            class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700"
          >
            <UIcon name="i-heroicons-user" class="h-8 w-8 text-gray-400" />
          </div>
        </div>
        <div>
          <UButton variant="outline" size="sm" :loading="uploading" @click="fileInput?.click()">
            &#123;&#123; t('settings.changeAvatar') }}
          </UButton>
          <input
            ref="fileInput"
            type="file"
            accept="image/*"
            class="hidden"
            @change="handleAvatarChange"
          />
        </div>
      </div>

      <!-- Display name -->
      <UFormField :label="t('settings.displayName')" :error="fieldErrors.display_name">
        <UInput v-model="form.display_name" class="w-full" />
      </UFormField>

      <!-- Phones -->
      <CabinetSettingsPhoneManager />

      <!-- Account type -->
      <UFormField :label="t('settings.accountType')">
        <URadioGroup
          v-model="form.account_type"
          :items="[
            { label: t('settings.accountPersonal'), value: 'personal' },
            { label: t('settings.accountBusiness'), value: 'business' },
          ]"
        />
      </UFormField>

      <!-- Geo -->
      <GeoSelect
        @change="
          (sel: {
            region_id: number | undefined
            city_id: number | undefined
            district_id: number | undefined
            metro_station_id: number | undefined
          }) => {
            form.city_id = sel.city_id ?? null
            form.district_id = sel.district_id ?? null
            form.metro_station_id = sel.metro_station_id ?? null
          }
        "
      />

      <!-- Appearance -->
      <div>
        <h2 class="text-lg font-semibold mb-3">
          &#123;&#123; t('settings.appearance') }}
        </h2>
        <div class="flex items-center gap-4">
          <label class="text-sm font-medium">&#123;&#123; t('settings.theme') }}</label>
          <UColorModeSelect />
        </div>
      </div>

      <!-- Save -->
      <UButton color="primary" :loading="saving" @click="handleSave">
        &#123;&#123; t('common.save') }}
      </UButton>
    </div>
  </div>
</template>

Step 2: Run lint

Run: npm run lint:fix Expected: PASS

Step 3: Commit

bash
git add app/pages/cabinet/settings/profile.vue
git commit -m "feat(phone): replace single phone input with PhoneManager in profile"

Task 7: Add phone_id to product form

Files:

  • Modify: app/features/product-form/composables/useProductForm.ts:22-40,93,132-149
  • Modify: app/features/product-form/ui/ProductFormPage.vue:1-31,370-381

Step 1: Update useProductForm — add phone_id to form, loadProduct, buildRequestBody

In app/features/product-form/composables/useProductForm.ts:

Add phone_id to the form reactive. Replace lines 22-40 with:

ts
const form = reactive<ProductForm>({
  title: mode === 'create' ? PRODUCT_DEFAULT_TITLE : '',
  description: '',
  price: mode === 'create' ? PRODUCT_DEFAULT_PRICE : 0,
  steering: 'universal',
  oem_number: '',
  manufacturer: '',
  region_id: undefined,
  city_id: 0,
  district_id: undefined,
  metro_station_id: undefined,
  address: '',
  phone_id: null,
  category_ids: [],
  condition_category_id: 0,
  attribute_category_id: 0,
  compatibility: [],
  is_kit: false,
  oem_numbers: [] as string[],
})

In loadProduct(), after line 93 (form.address = p.address ?? ''), add:

ts
form.phone_id = p.phone_id ?? null

In buildRequestBody(), after line 145 (address,), add:

ts
      phone_id: form.phone_id,

Step 2: Update ProductFormPage.vue — add phone selector

In app/features/product-form/ui/ProductFormPage.vue, add imports and phone logic after line 2 (after the existing import):

ts
import { formatPhone } from '~/shared/lib/phone'

After line 31 (} = useProductForm(props.mode, props.productId)), add:

ts
// Phone selector
const authStore = useAuthStore()

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

// Auto-select primary phone on create
watch(
  () => authStore.user?.phones,
  (userPhones) => {
    if (form.phone_id === null && userPhones?.length) {
      const primary = userPhones.find((p) => p.is_primary) ?? userPhones[0]
      form.phone_id = primary.id
    }
  },
  { immediate: true },
)

In the template, before line 372 (<!-- Location -->), add:

html
<!-- Contact phone -->
<UFormField
  :label="t('listing.contactPhone')"
  name="phone_id"
  :error="fieldErrors['phone_id']?.[0]"
>
        <template v-if="phoneOptions.length > 0">
          <USelectMenu
            v-model="form.phone_id"
            :items="phoneOptions"
            :placeholder="t('listing.selectPhone')"
            value-key="value"
            class="w-full"
          />
        </template>
        <div v-else class="text-sm text-[var(--ui-text-muted)]">
          &#123;&#123; t('listing.noPhones') }}.
          <NuxtLink to="/cabinet/settings/profile" class="text-primary hover:underline">
            &#123;&#123; t('listing.addPhoneInSettings') }}
          </NuxtLink>
        </div>
      </UFormField>

Step 3: Run lint

Run: npm run lint:fix Expected: PASS

Step 4: Commit

bash
git add app/features/product-form/composables/useProductForm.ts app/features/product-form/ui/ProductFormPage.vue
git commit -m "feat(phone): add phone selector to product form"

Task 8: Fix remaining user.phone references

Files:

  • Modify: app/pages/admin/users/[id].vue:117-118

Step 1: Search for stale references

Run: grep -rn 'user\.phone\b' app/ --include='*.vue' --include='*.ts' | grep -v 'user\.phones' | grep -v 'user\.phone_' | grep -v '.test.ts'

Expected: Only app/pages/admin/users/[id].vue:118 — admin user detail showing phone.

Step 2: Update admin user detail page

In app/pages/admin/users/[id].vue, replace line 117-118:

html
<dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('auth.phone') }}</dt>
<dd class="font-medium">&#123;&#123; user.phone ?? t('admin.notSpecified') }}</dd>

with:

html
<dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('auth.phone') }}</dt>
<dd class="font-medium">
  &#123;&#123; user.phones?.length ? user.phones.find(p => p.is_primary)?.phone ?? user.phones[0].phone :
  t('admin.notSpecified') }}
</dd>

Step 3: Run full test suite

Run: npx vitest run Expected: All tests pass

Step 4: Run lint and typecheck

Run: npm run lint:fix Expected: PASS

Step 5: Commit

bash
git add app/pages/admin/users/[id].vue
git commit -m "fix(phone): update admin user detail to use phones array"

Task 9: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Step 1: Update documentation

In CLAUDE.md:

  1. In the User Zod Schemas section, replace phone: z.string().nullable() description with:
phones: UserPhone[] (userPhoneSchema: { id, user_id, phone, label, is_primary, sort_order, phone_verified, created_at })
  1. In the Product schemas section, add phone_id: number | null to both productDetailSchema and productFormSchema.

  2. In Vendor Endpoints table, add:

| List phones | GET | `/vendor/me/phones` | Список телефонов пользователя |
| Add phone | POST | `/vendor/me/phones` | Добавить телефон (макс 5) |
| Update phone | PUT | `/vendor/me/phones/{id}` | Обновить телефон |
| Delete phone | DELETE | `/vendor/me/phones/{id}` | Удалить телефон |
  1. In Project Structure under features/cabinet-settings/, add:
│       ├── ui/PhoneManager.vue             # Phone CRUD (add/edit/delete with modals)
  1. In Progress, add:
- [x] Multi-phone management (profile settings, product form phone selector)

Step 2: Commit

bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with multi-phone feature"

Execution Summary

TaskDescriptionKey files
1Schemas (UserPhone, remove phone, add phone_id)entities/user/model/user.schema.ts, shared/schemas/settings.ts, entities/product/model/product.schema.ts
2Remove phone from useProfileForm + fix testfeatures/cabinet-settings/composables/useProfileForm.ts, .test.ts
3usePhoneManager composable + testsfeatures/cabinet-settings/composables/usePhoneManager.ts, .test.ts
4i18n keysi18n/locales/ru.json
5PhoneManager.vue componentfeatures/cabinet-settings/ui/PhoneManager.vue
6Integrate into profile.vuepages/cabinet/settings/profile.vue
7Phone selector in product formfeatures/product-form/composables/useProductForm.ts, ui/ProductFormPage.vue
8Fix remaining user.phone refspages/admin/users/[id].vue
9Update CLAUDE.mdCLAUDE.md

Dependencies: 1 → 2 → 6, 1 → 3 → 5 → 6, 1 → 7, 4 → 5, 8 after all code tasks, 9 last.