Appearance
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">{{ t('settings.phones') }}</label>
<UButton
v-if="canAdd()"
variant="link"
size="sm"
icon="i-lucide-plus"
@click="openAddModal"
>
{{ t('settings.addPhone') }}
</UButton>
<span v-else class="text-xs text-gray-400">{{ 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>{{ t('settings.phonesEmpty') }}</p>
<UButton variant="link" size="sm" class="mt-1" @click="openAddModal">
{{ 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">{{ formatPhone(phone.phone) }}</span>
<UBadge v-if="phone.is_primary" color="primary" variant="subtle" size="xs">
{{ t('settings.phonePrimary') }}
</UBadge>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ 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>
{{ 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">{{ t('settings.phoneMakePrimary') }}</span>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-3">
<UButton variant="outline" @click="modalOpen = false">
{{ t('common.cancel') }}
</UButton>
<UButton color="primary" :loading="saving" @click="handleSave">
{{ t('common.save') }}
</UButton>
</div>
</template>
</UModal>
<!-- Delete confirm modal -->
<UModal v-model:open="deleteModalOpen">
<template #header>
{{ t('settings.deletePhone') }}
</template>
<template #body>
<p>{{ 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">
{{ t('common.cancel') }}
</UButton>
<UButton color="error" :loading="deleting !== null" @click="handleDelete">
{{ 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 ?? nullIn 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">
{{ t('listing.noPhones') }}
<NuxtLink to="/cabinet/settings/profile" class="text-primary hover:underline">
{{ 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.phoneare 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:
- 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 })In Product schemas section, add
phone_idtoproductFormSchemaandproductDetailSchema.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}` | Удалить телефон |- In Project Structure, add under
features/cabinet-settings/:
│ ├── ui/PhoneManager.vue # Phone management (add/edit/delete with modals)- In Project Structure, add under
shared/lib/:
│ ├── lib/
│ │ └── phone.ts # Phone format/unmask/validate utilities- 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
| Task | Description | Key files |
|---|---|---|
| 1 | Phone utilities + schemas | shared/lib/phone.ts, entities/user/model/user.schema.ts, shared/schemas/settings.ts |
| 2 | Phone manager composable | features/cabinet-settings/composables/usePhoneManager.ts |
| 3 | Phone list UI in profile | features/cabinet-settings/ui/PhoneManager.vue, pages/cabinet/settings/profile.vue |
| 4 | Phone selector in product form | entities/product/model/product.schema.ts, features/product-form/ |
| 5 | Verify & fix references | Grep for stale user.phone/form.phone refs |
| 6 | Update CLAUDE.md | CLAUDE.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.