Skip to content

Multi-Phone Frontend Implementation Plan

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

Goal: Replace single phone input in profile settings with multi-phone management (up to 5 phones with labels), add phone selector to product form, validate Russian phone format.

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. Input mask on frontend, E.164 on backend.

Tech Stack: Zod (validation), Pinia (auth store), Nuxt UI v3 (USelectMenu, UModal, UInput), vue-the-mask or manual mask helper.

Backend proposal: docs/plans/2026-02-23-multi-phone-backend-proposal.md


Task 1: Phone schema + Zod validation

Files:

  • Modify: app/entities/user/model/user.schema.ts
  • Create: app/shared/lib/phone.ts
  • Create: app/shared/lib/phone.test.ts

Step 1: Write failing tests for phone utilities

Create app/shared/lib/phone.test.ts:

ts
import { describe, it, expect } from 'vitest'

import { formatPhone, unmaskPhone, isValidRussianPhone } from './phone'

describe('phone utilities', () => {
  describe('unmaskPhone', () => {
    it('strips mask characters to E.164', () => {
      expect(unmaskPhone('+7 (921) 987-65-43')).toBe('+79219876543')
    })
    it('handles already clean number', () => {
      expect(unmaskPhone('+79219876543')).toBe('+79219876543')
    })
    it('handles partial input', () => {
      expect(unmaskPhone('+7 (921) 98')).toBe('+792198')
    })
    it('returns empty string for empty input', () => {
      expect(unmaskPhone('')).toBe('')
      expect(unmaskPhone(null as unknown as string)).toBe('')
    })
  })

  describe('formatPhone', () => {
    it('formats E.164 to display format', () => {
      expect(formatPhone('+79219876543')).toBe('+7 (921) 987-65-43')
    })
    it('handles short numbers gracefully', () => {
      expect(formatPhone('+7921')).toBe('+7 (921) ')
    })
    it('returns empty string for empty input', () => {
      expect(formatPhone('')).toBe('')
      expect(formatPhone(null as unknown as string)).toBe('')
    })
  })

  describe('isValidRussianPhone', () => {
    it('accepts valid +7 numbers', () => {
      expect(isValidRussianPhone('+79219876543')).toBe(true)
    })
    it('rejects too short', () => {
      expect(isValidRussianPhone('+7921987654')).toBe(false)
    })
    it('rejects too long', () => {
      expect(isValidRussianPhone('+792198765431')).toBe(false)
    })
    it('rejects non-+7 prefix', () => {
      expect(isValidRussianPhone('+19219876543')).toBe(false)
    })
    it('rejects letters', () => {
      expect(isValidRussianPhone('+7921abc6543')).toBe(false)
    })
    it('rejects empty', () => {
      expect(isValidRussianPhone('')).toBe(false)
    })
  })
})

Step 2: Run tests to verify they fail

Run: npx vitest run app/shared/lib/phone.test.ts Expected: FAIL — module ./phone not found

Step 3: Implement phone utilities

Create app/shared/lib/phone.ts:

ts
const RUSSIAN_PHONE_REGEX = /^\+7\d{10}$/

/**
 * Remove mask characters, keep only +, digits.
 * "+7 (921) 987-65-43" → "+79219876543"
 */
export function unmaskPhone(value: string | null | undefined): string {
  if (!value) return ''
  return value.replace(/[^+\d]/g, '')
}

/**
 * Format E.164 Russian phone for display.
 * "+79219876543" → "+7 (921) 987-65-43"
 */
export function formatPhone(value: string | null | undefined): string {
  if (!value) return ''
  const digits = value.replace(/[^+\d]/g, '')
  if (digits.length < 2) return digits

  // +7 XXXXXXXXXX
  const rest = digits.slice(2) // after +7
  let result = '+7'

  if (rest.length > 0) result += ` (${rest.slice(0, 3)}) `
  if (rest.length > 3) result += `${rest.slice(3, 6)}`
  if (rest.length > 6) result += `-${rest.slice(6, 8)}`
  if (rest.length > 8) result += `-${rest.slice(8, 10)}`

  return result.trimEnd()
}

/**
 * Validate E.164 Russian mobile phone: +7XXXXXXXXXX (12 chars)
 */
export function isValidRussianPhone(value: string): boolean {
  return RUSSIAN_PHONE_REGEX.test(value)
}

Step 4: Run tests to verify they pass

Run: npx vitest run app/shared/lib/phone.test.ts Expected: PASS (all 11 tests)

Step 5: Add UserPhone schema to user entity

Modify app/entities/user/model/user.schema.ts — add after accountTypeSchema (line 3):

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

In userSchema — replace line 9 phone: z.string().nullable() with:

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

Add type export at the end:

ts
export type UserPhone = z.infer<typeof userPhoneSchema>

Step 6: Update settings schema

Modify app/shared/schemas/settings.ts — remove line 5 phone: z.string().nullable() from profileFormSchema. The phones are managed separately, not as part of profile form.

Updated profileFormSchema:

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 7: Run lint and typecheck

Run: npm run lint:fix && npx nuxi typecheck Expected: May have errors in files still referencing form.phone or user.phone — those will be fixed in subsequent tasks.

Step 8: Commit

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

Task 2: Phone manager composable

Files:

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

Step 1: Write failing tests

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

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

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

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

  it('loadPhones fetches and populates phones list', async () => {
    registerEndpoint('/api/vendor/me/phones', {
      method: 'GET',
      handler: () => ({
        data: [
          { id: 1, phone: '+79219876543', label: 'Основной', is_primary: true, phone_verified_at: null, sort_order: 0, created_at: '2026-01-01T00:00:00Z' },
        ],
      }),
    })

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

    expect(manager.phones.value).toHaveLength(1)
    expect(manager.phones.value[0].phone).toBe('+79219876543')
  })

  it('addPhone posts new phone and refreshes list', async () => {
    registerEndpoint('/api/vendor/me/phones', {
      method: 'GET',
      handler: () => ({ data: [] }),
    })
    registerEndpoint('/api/vendor/me/phones', {
      method: 'POST',
      handler: () => ({
        data: { id: 1, phone: '+79219876543', label: 'Основной', is_primary: true, phone_verified_at: null, sort_order: 0, created_at: '2026-01-01T00:00:00Z' },
      }),
    })

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

    expect(result).toBe(true)
  })

  it('addPhone returns false and sets errors on validation failure', async () => {
    registerEndpoint('/api/vendor/me/phones', {
      method: 'POST',
      handler: () => {
        throw createError({
          statusCode: 422,
          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 refreshes', async () => {
    let deleteCalled = false
    registerEndpoint('/api/vendor/me/phones/1', {
      method: 'DELETE',
      handler: () => {
        deleteCalled = true
        return null
      },
    })
    registerEndpoint('/api/vendor/me/phones', {
      method: 'GET',
      handler: () => ({ data: [] }),
    })

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

    expect(deleteCalled).toBe(true)
  })
})

Step 2: Run tests to verify they fail

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

Step 3: Implement usePhoneManager

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

ts
import { ref, readonly } from 'vue'

import type { UserPhone } from '~/entities/user/model/user.schema'
import type { ApiItemResponse, ApiListResponse } 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>>({})

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

  function canAdd(): boolean {
    return phones.value.length < MAX_PHONES
  }

  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),
    canAdd,
    loadPhones,
    addPhone,
    updatePhone,
    deletePhone,
  }
}

Step 4: Run tests to verify they pass

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

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"

Task 3: Phone list UI in profile settings

Files:

  • Create: app/features/cabinet-settings/ui/PhoneManager.vue
  • Modify: app/pages/cabinet/settings/profile.vue:99-106
  • Modify: app/features/cabinet-settings/composables/useProfileForm.ts:13,29,44
  • Modify: i18n/locales/ru.json (settings section)

Step 1: Add i18n keys

In i18n/locales/ru.json, in the "settings" object (after "phonePlaceholder" line 275), add:

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

Step 2: Create PhoneManager.vue 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 isCustomLabel = computed(() => !PHONE_LABELS.includes(modalLabel.value as typeof PHONE_LABELS[number]))

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.includes(phone.label as typeof PHONE_LABELS[number])) {
    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) {
  // Always keep +7 prefix
  const digits = value.replace(/[^+\d]/g, '')
  if (!digits.startsWith('+7')) {
    modalPhone.value = '+7 '
    return
  }
  // Limit to 12 chars (+7 + 10 digits)
  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-gray-400">&#123;&#123; t('settings.phoneMaxReached') }}</span>
    </div>

    <!-- Empty state -->
    <div
      v-if="phones.length === 0"
      class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400"
    >
      <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-gray-200 px-3 py-2 dark:border-gray-700"
      >
        <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-gray-500 dark:text-gray-400">&#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"
              label-key="label"
              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" />
            <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 3: Remove phone from useProfileForm

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

  • Line 13: Remove phone: null,
  • Line 29: Remove form.phone = u.phone
  • Line 44: Remove phone: form.phone,

Step 4: Replace phone input in profile page

Modify app/pages/cabinet/settings/profile.vue — replace lines 99-106 (phone UFormField) with:

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

Step 5: Run lint and verify

Run: npm run lint:fix Expected: PASS

Step 6: Commit

bash
git add app/features/cabinet-settings/ui/PhoneManager.vue app/features/cabinet-settings/composables/useProfileForm.ts app/pages/cabinet/settings/profile.vue i18n/locales/ru.json
git commit -m "feat(phone): add PhoneManager UI with add/edit/delete modals"

Task 4: Phone selector in product form

Files:

  • Modify: app/entities/product/model/product.schema.ts:120-138
  • Modify: app/features/product-form/composables/useProductForm.ts:22-40,72-108,117-151
  • Modify: app/features/product-form/ui/ProductFormPage.vue:407-417
  • Modify: i18n/locales/ru.json (listing section)

Step 1: Add phone_id to product schemas

Modify app/entities/product/model/product.schema.ts:

In productDetailSchema (line 82), add after address:

ts
  phone_id: z.number().nullable(),

In productFormSchema (line 120), add after address:

ts
  phone_id: z.number().nullable().default(null),

Step 2: Add i18n keys

In i18n/locales/ru.json, in "listing" section, add:

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

Step 3: Update useProductForm composable

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

In form reactive (line 22-40), add after oem_numbers:

ts
    phone_id: null as number | null,

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

ts
    form.phone_id = p.phone_id ?? null

In buildRequestBody() (line 132 body object), add after address:

ts
      phone_id: form.phone_id,

Step 4: Update ProductFormPage.vue

Modify app/features/product-form/ui/ProductFormPage.vue:

Add import and setup in <script setup> (after line 31):

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

const authStore = useAuthStore()

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

// Set default phone_id to primary on mount if not set
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 })

In template, add before <!-- Location --> section (before line 408):

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"
            label-key="label"
            class="w-full"
          />
        </template>
        <div v-else class="text-sm text-gray-500 dark:text-gray-400">
          &#123;&#123; t('listing.noPhones') }}
          <NuxtLink to="/cabinet/settings/profile" class="text-primary hover:underline">
            &#123;&#123; t('listing.addPhoneInSettings') }}
          </NuxtLink>
        </div>
      </UFormField>

Step 5: Run lint and typecheck

Run: npm run lint:fix Expected: PASS

Step 6: Commit

bash
git add app/entities/product/model/product.schema.ts app/features/product-form/composables/useProductForm.ts app/features/product-form/ui/ProductFormPage.vue i18n/locales/ru.json
git commit -m "feat(phone): add phone selector to product form"

Task 5: Update auth store and consumers

Files:

  • Modify: app/stores/auth.ts (no changes needed — types flow from User schema)
  • Modify: app/pages/cabinet/settings/profile.vue (already updated in Task 3)
  • Verify: All pages referencing user.phone are updated

Step 1: Search for remaining user.phone references

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

If any references found, update them.

Step 2: Search for form.phone references outside settings

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

Only useProfileForm.ts should have been updated in Task 3. If others exist, fix them.

Step 3: Run full test suite

Run: npx vitest run Expected: All tests pass (existing + new phone tests)

Step 4: Run lint and typecheck

Run: npm run lint:fix && npx nuxi typecheck Expected: PASS for files we modified. Pre-existing errors in unrelated files are acceptable.

Step 5: Commit (if any fixes)

bash
git add -A
git commit -m "fix(phone): update remaining user.phone references"

Task 6: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Step 1: Update documentation

In CLAUDE.md:

  1. In User schema section, replace phone: z.string().nullable() with:
phones: UserPhone[] (see userPhoneSchema: { id, phone, label, is_primary, phone_verified_at, sort_order, created_at })
  1. In Product schemas section, add phone_id to productFormSchema and productDetailSchema.

  2. In Vendor Endpoints, 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, add under features/cabinet-settings/:
│       ├── ui/PhoneManager.vue             # Phone management (add/edit/delete with modals)
  1. In Project Structure, add under shared/lib/:
│   ├── lib/
│   │   └── phone.ts              # Phone format/unmask/validate utilities
  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
1Phone utilities + schemasshared/lib/phone.ts, entities/user/model/user.schema.ts, shared/schemas/settings.ts
2Phone manager composablefeatures/cabinet-settings/composables/usePhoneManager.ts
3Phone list UI in profilefeatures/cabinet-settings/ui/PhoneManager.vue, pages/cabinet/settings/profile.vue
4Phone selector in product formentities/product/model/product.schema.ts, features/product-form/
5Verify & fix referencesGrep for stale user.phone/form.phone refs
6Update CLAUDE.mdCLAUDE.md

Dependencies: Task 1 → Task 2 → Task 3. Task 1 → Task 4. Task 5 after all. Task 6 last.

Note: Backend must implement /vendor/me/phones endpoints and add phones[] to /auth/me response before frontend can be tested end-to-end. Frontend can be built in parallel using mock data / test endpoints.