Appearance
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">{{ 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-[var(--ui-text-muted)]">{{
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>{{ 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-[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">{{ 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-[var(--ui-text-muted)]">{{ 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"
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">{{ 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 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">
{{ 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()">
{{ 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">
{{ t('settings.appearance') }}
</h2>
<div class="flex items-center gap-4">
<label class="text-sm font-medium">{{ t('settings.theme') }}</label>
<UColorModeSelect />
</div>
</div>
<!-- Save -->
<UButton color="primary" :loading="saving" @click="handleSave">
{{ 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 ?? nullIn 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)]">
{{ t('listing.noPhones') }}.
<NuxtLink to="/cabinet/settings/profile" class="text-primary hover:underline">
{{ 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)]">{{ t('auth.phone') }}</dt>
<dd class="font-medium">{{ user.phone ?? t('admin.notSpecified') }}</dd>with:
html
<dt class="text-[var(--ui-text-muted)]">{{ t('auth.phone') }}</dt>
<dd class="font-medium">
{{ 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:
- 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 })In the Product schemas section, add
phone_id: number | nullto bothproductDetailSchemaandproductFormSchema.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}` | Удалить телефон |- In Project Structure under
features/cabinet-settings/, add:
│ ├── ui/PhoneManager.vue # Phone CRUD (add/edit/delete with modals)- 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 | Schemas (UserPhone, remove phone, add phone_id) | entities/user/model/user.schema.ts, shared/schemas/settings.ts, entities/product/model/product.schema.ts |
| 2 | Remove phone from useProfileForm + fix test | features/cabinet-settings/composables/useProfileForm.ts, .test.ts |
| 3 | usePhoneManager composable + tests | features/cabinet-settings/composables/usePhoneManager.ts, .test.ts |
| 4 | i18n keys | i18n/locales/ru.json |
| 5 | PhoneManager.vue component | features/cabinet-settings/ui/PhoneManager.vue |
| 6 | Integrate into profile.vue | pages/cabinet/settings/profile.vue |
| 7 | Phone selector in product form | features/product-form/composables/useProductForm.ts, ui/ProductFormPage.vue |
| 8 | Fix remaining user.phone refs | pages/admin/users/[id].vue |
| 9 | Update CLAUDE.md | CLAUDE.md |
Dependencies: 1 → 2 → 6, 1 → 3 → 5 → 6, 1 → 7, 4 → 5, 8 after all code tasks, 9 last.