Appearance
Cabinet: Favorites + Settings — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement favorites system (API-backed store, UI buttons, favorites page) and settings pages (profile, security, business profile) in the personal cabinet.
Architecture: FSD layers — composables in features/, pages in pages/cabinet/, store in stores/. Favorites store gets API integration with optimistic UI. Settings split into 3 subpages with shared tab navigation. All forms use Zod validation + useApiError for backend errors.
Tech Stack: Nuxt 4, Vue 3.5, Pinia, Nuxt UI v3, Zod, Vitest
Task 1: Favorites Store — API Integration
Files:
- Modify:
app/stores/favorites.ts - Test:
app/stores/favorites.test.ts
Step 1: Write the failing test
Create app/stores/favorites.test.ts:
typescript
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockDelete = vi.fn()
mockNuxtImport('useApiClient', () => () => ({
get: mockGet,
post: mockPost,
delete: mockDelete,
}))
const { useFavoritesStore } = await import('./favorites')
describe('useFavoritesStore', () => {
beforeEach(() => {
vi.clearAllMocks()
const store = useFavoritesStore()
store.$reset()
})
it('fetchAll loads favorites from API and populates ids + items', async () => {
mockGet.mockResolvedValue({
data: [
{ id: 1, title: 'Фара BMW', price: 5000, status: 'active' },
{ id: 2, title: 'Бампер Audi', price: 3000, status: 'active' },
],
})
const store = useFavoritesStore()
await store.fetchAll()
expect(mockGet).toHaveBeenCalledWith('/vendor/favorites')
expect(store.ids.has(1)).toBe(true)
expect(store.ids.has(2)).toBe(true)
expect(store.items).toHaveLength(2)
})
it('toggle adds product via API when not favorited', async () => {
mockPost.mockResolvedValue({})
const store = useFavoritesStore()
await store.toggle(10)
expect(store.ids.has(10)).toBe(true)
expect(mockPost).toHaveBeenCalledWith('/vendor/favorites', { product_id: 10 })
})
it('toggle removes product via API when already favorited', async () => {
mockDelete.mockResolvedValue({})
const store = useFavoritesStore()
store.ids.add(10)
await store.toggle(10)
expect(store.ids.has(10)).toBe(false)
expect(mockDelete).toHaveBeenCalledWith('/vendor/favorites/10')
})
it('toggle rolls back on API error', async () => {
mockPost.mockRejectedValue(new Error('Network'))
const store = useFavoritesStore()
await store.toggle(10)
expect(store.ids.has(10)).toBe(false)
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/stores/favorites.test.ts Expected: FAIL — fetchAll doesn't exist, toggle is synchronous
Step 3: Write minimal implementation
Replace app/stores/favorites.ts:
typescript
import { defineStore } from 'pinia'
import { useApiClient } from '~/shared/api/client'
import type { Product } from '~/entities/product/model/product.schema'
import type { ApiListResponse } from '~/shared/api/types'
interface FavoritesState {
ids: Set<number>
itemsMap: Map<number, Product>
}
export const useFavoritesStore = defineStore('favorites', {
state: (): FavoritesState => ({
ids: new Set(),
itemsMap: new Map(),
}),
getters: {
count: (state) => state.ids.size,
isFavorite: (state) => (productId: number) => state.ids.has(productId),
items: (state) => Array.from(state.itemsMap.values()),
},
actions: {
async fetchAll() {
const api = useApiClient()
const response = await api.get<ApiListResponse<Product>>('/vendor/favorites')
this.ids = new Set(response.data.map(p => p.id))
this.itemsMap = new Map(response.data.map(p => [p.id, p]))
},
async toggle(productId: number) {
const api = useApiClient()
const wasFavorited = this.ids.has(productId)
// Optimistic update
if (wasFavorited) {
this.ids.delete(productId)
this.itemsMap.delete(productId)
} else {
this.ids.add(productId)
}
try {
if (wasFavorited) {
await api.delete(`/vendor/favorites/${productId}`)
} else {
await api.post('/vendor/favorites', { product_id: productId })
}
} catch {
// Rollback
if (wasFavorited) {
this.ids.add(productId)
} else {
this.ids.delete(productId)
}
}
},
setAll(ids: number[]) {
this.ids = new Set(ids)
},
},
})Step 4: Run test to verify it passes
Run: npx vitest run app/stores/favorites.test.ts Expected: PASS (4 tests)
Step 5: Commit
bash
git add app/stores/favorites.ts app/stores/favorites.test.ts
git commit -m "feat(favorites): add API integration to favorites store with optimistic toggle"Task 2: Hydrate Favorites on App Init
Files:
- Modify:
app/plugins/auth.ts
Step 1: Modify auth plugin to fetch favorites after user hydration
In app/plugins/auth.ts, after await authStore.fetchUser(), add:
typescript
export default defineNuxtPlugin(async () => {
const authStore = useAuthStore()
const favoritesStore = useFavoritesStore()
if (import.meta.client) {
await authStore.initSession()
}
await authStore.fetchUser()
// Hydrate favorites for authenticated users (client-only, non-blocking)
if (import.meta.client && authStore.isAuthenticated) {
favoritesStore.fetchAll().catch(() => {})
}
})Note: fetchAll() is fire-and-forget (non-blocking) — page loads immediately, favorites appear when ready.
Step 2: Verify app starts without errors
Run: npm run dev — open http://localhost:3000, check no console errors.
Step 3: Commit
bash
git add app/plugins/auth.ts
git commit -m "feat(favorites): hydrate favorites store on app init for authenticated users"Task 3: FavoriteButton Component
Files:
- Create:
app/features/favorites/ui/FavoriteButton.vue
Step 1: Create the component
html
<script setup lang="ts">
const props = defineProps<{
productId: number
}>()
const authStore = useAuthStore()
const favoritesStore = useFavoritesStore()
const toggling = ref(false)
const isFavorited = computed(() => favoritesStore.isFavorite(props.productId))
async function handleToggle() {
if (toggling.value) return
toggling.value = true
try {
await favoritesStore.toggle(props.productId)
} finally {
toggling.value = false
}
}
</script>
<template>
<button
v-if="authStore.isAuthenticated"
class="flex items-center justify-center rounded-full p-1.5 transition-transform active:scale-90"
:class="isFavorited
? 'text-red-500'
: 'text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400'"
:disabled="toggling"
:aria-label="isFavorited ? $t('product.removeFromFavorites') : $t('product.addToFavorites')"
@click.prevent="handleToggle"
>
<UIcon
:name="isFavorited ? 'i-heroicons-heart-solid' : 'i-heroicons-heart'"
class="h-5 w-5"
/>
</button>
</template>Step 2: Verify it renders
Run dev server, navigate to a product card (while logged in). The heart icon won't show yet — we integrate it in the next task.
Step 3: Commit
bash
git add app/features/favorites/ui/FavoriteButton.vue
git commit -m "feat(favorites): add FavoriteButton component with optimistic toggle"Task 4: Integrate FavoriteButton into ProductCard and Product Detail
Files:
- Modify:
app/entities/product/ui/ProductCard.vue - Modify:
app/pages/product/[id].vue
Step 1: Add FavoriteButton to ProductCard
In app/entities/product/ui/ProductCard.vue, add the button inside the image container <div class="relative aspect-[4/3]...">, after the ProductStatusBadge:
html
<!-- Add after ProductStatusBadge, inside the relative image container -->
<FavoritesFavoriteButton
:product-id="product.id"
class="absolute right-2 top-2 bg-white/80 dark:bg-gray-900/80 rounded-full backdrop-blur-sm"
/>Note: FSD auto-import prefix — features/favorites/ui/FavoriteButton.vue → FavoritesFavoriteButton.
Step 2: Add FavoriteButton to product detail page
In app/pages/product/[id].vue, add after the price line <p class="text-3xl font-bold mt-2">:
html
<div class="flex items-center gap-3 mt-2">
<p class="text-3xl font-bold">{{ formattedPrice }}</p>
<FavoritesFavoriteButton
v-if="product"
:product-id="product.id"
class="scale-125"
/>
</div>Remove the existing standalone <p class="text-3xl font-bold mt-2">{{ formattedPrice }}</p> line and replace with the above.
Step 3: Verify visually
Run dev server. Navigate to catalog — hearts visible on cards (if logged in). Navigate to product detail — heart next to price.
Step 4: Commit
bash
git add app/entities/product/ui/ProductCard.vue app/pages/product/[id].vue
git commit -m "feat(favorites): integrate FavoriteButton into ProductCard and product detail"Task 5: Favorites Page
Files:
- Modify:
app/pages/cabinet/favorites.vue
Step 1: Add i18n keys
In i18n/locales/ru.json, add a "favorites" section (at root level):
json
"favorites": {
"title": "Избранное",
"count": "{count} товаров",
"empty": "Вы пока ничего не добавили в избранное",
"goToCatalog": "Перейти в каталог"
}Step 2: Implement the favorites page
Replace app/pages/cabinet/favorites.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Избранное', robots: 'noindex, nofollow' })
const { t } = useI18n()
const favoritesStore = useFavoritesStore()
</script>
<template>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold">{{ t('favorites.title') }}</h1>
<p v-if="favoritesStore.count > 0" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ t('favorites.count', { count: favoritesStore.count }) }}
</p>
</div>
<!-- Empty state -->
<div v-if="favoritesStore.count === 0" class="text-center py-12">
<UIcon name="i-heroicons-heart" class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="text-gray-500 dark:text-gray-400 mt-4 mb-4">{{ t('favorites.empty') }}</p>
<UButton color="primary" to="/catalog">
{{ t('favorites.goToCatalog') }}
</UButton>
</div>
<!-- Product grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<ProductCard
v-for="product in favoritesStore.items"
:key="product.id"
:product="product"
/>
</div>
</div>
</template>Step 3: Verify
Run dev server, log in, add some favorites, navigate to /cabinet/favorites.
Step 4: Commit
bash
git add app/pages/cabinet/favorites.vue i18n/locales/ru.json
git commit -m "feat(favorites): implement favorites page with grid and empty state"Task 6: Settings — i18n Keys + Zod Schemas
Files:
- Modify:
i18n/locales/ru.json - Create:
app/shared/schemas/settings.ts
Step 1: Add i18n keys for settings
In i18n/locales/ru.json, expand the "settings" section:
json
"settings": {
"appearance": "Оформление",
"theme": "Тема",
"themeDescription": "Следует настройкам вашего устройства",
"profile": "Профиль",
"security": "Безопасность",
"business": "Бизнес",
"displayName": "Имя",
"phone": "Телефон",
"phonePlaceholder": "+7 (___) ___-__-__",
"accountType": "Тип аккаунта",
"accountPersonal": "Частное лицо",
"accountBusiness": "Бизнес",
"avatar": "Фото профиля",
"changeAvatar": "Изменить",
"profileSaved": "Профиль сохранён",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтверждение пароля",
"changePassword": "Изменить пароль",
"passwordChanged": "Пароль изменён",
"sessions": "Активные сессии",
"currentSession": "Текущая",
"endSession": "Завершить",
"endAllSessions": "Завершить все кроме текущей",
"sessionEnded": "Сессия завершена",
"allSessionsEnded": "Все сессии завершены",
"companyName": "Название компании",
"inn": "ИНН",
"innPlaceholder": "10 или 12 цифр",
"companyAddress": "Адрес",
"businessSaved": "Бизнес-профиль сохранён"
}Step 2: Create Zod schemas for settings forms
Create app/shared/schemas/settings.ts:
typescript
import { z } from 'zod'
export const profileFormSchema = z.object({
display_name: z.string().min(1, 'Введите имя').max(100, 'Максимум 100 символов'),
phone: z.string().nullable(),
account_type: z.enum(['personal', 'business']),
city_id: z.number().nullable(),
district_id: z.number().nullable(),
metro_station_id: z.number().nullable(),
})
const passwordSchema = z
.string()
.min(8, 'Минимум 8 символов')
.max(128, 'Максимум 128 символов')
.regex(/[A-Z]/, 'Минимум одна заглавная буква')
.regex(/[a-z]/, 'Минимум одна строчная буква')
.regex(/[0-9]/, 'Минимум одна цифра')
.regex(/[^a-zA-Z0-9]/, 'Минимум один спецсимвол')
.refine(
(val) => !/(.)\1{2,}/.test(val),
'Не допускается 3+ одинаковых символа подряд',
)
export const changePasswordSchema = z
.object({
current_password: z.string().min(1, 'Введите текущий пароль'),
password: passwordSchema,
password_confirmation: z.string().min(1, 'Повторите пароль'),
})
.refine((data) => data.password === data.password_confirmation, {
message: 'Пароли не совпадают',
path: ['password_confirmation'],
})
export const businessFormSchema = z.object({
company_name: z.string().min(1, 'Введите название компании'),
inn: z
.string()
.nullable()
.refine(
(val) => !val || /^\d{10}$|^\d{12}$/.test(val),
'ИНН должен содержать 10 или 12 цифр',
),
address: z.string().nullable(),
})
export type ProfileForm = z.infer<typeof profileFormSchema>
export type ChangePasswordForm = z.infer<typeof changePasswordSchema>
export type BusinessForm = z.infer<typeof businessFormSchema>Step 3: Commit
bash
git add i18n/locales/ru.json app/shared/schemas/settings.ts
git commit -m "feat(settings): add i18n keys and Zod schemas for settings forms"Task 7: Settings Tabs Navigation + Route Redirect
Files:
- Create:
app/features/cabinet-settings/ui/SettingsTabs.vue - Delete:
app/pages/cabinet/settings.vue - Create:
app/pages/cabinet/settings/index.vue
Step 1: Create SettingsTabs component
Create app/features/cabinet-settings/ui/SettingsTabs.vue:
html
<script setup lang="ts">
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const tabs = computed(() => {
const list = [
{ label: t('settings.profile'), to: '/cabinet/settings/profile' },
{ label: t('settings.security'), to: '/cabinet/settings/security' },
]
if (authStore.user?.account_type === 'business') {
list.push({ label: t('settings.business'), to: '/cabinet/settings/business' })
}
return list
})
const activeIndex = computed(() => {
const idx = tabs.value.findIndex(tab => route.path === tab.to)
return idx >= 0 ? idx : 0
})
</script>
<template>
<div class="mb-6">
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700">
<NuxtLink
v-for="(tab, index) in tabs"
:key="tab.to"
:to="tab.to"
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
:class="index === activeIndex
? 'border-primary text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
>
{{ tab.label }}
</NuxtLink>
</div>
</div>
</template>Step 2: Delete old settings.vue and create redirect
Delete app/pages/cabinet/settings.vue.
Create app/pages/cabinet/settings/index.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
await navigateTo('/cabinet/settings/profile', { replace: true })
</script>
<template>
<div />
</template>Step 3: Update cabinet layout sidebar link
In app/layouts/cabinet.vue, the settings link (to="/cabinet/settings") already exists and will redirect correctly. No change needed.
Step 4: Commit
bash
git rm app/pages/cabinet/settings.vue
git add app/features/cabinet-settings/ui/SettingsTabs.vue app/pages/cabinet/settings/index.vue
git commit -m "feat(settings): add settings tabs navigation and route redirect"Task 8: Profile Form Composable
Files:
- Create:
app/features/cabinet-settings/composables/useProfileForm.ts - Test:
app/features/cabinet-settings/composables/useProfileForm.test.ts
Step 1: Write the failing test
Create app/features/cabinet-settings/composables/useProfileForm.test.ts:
typescript
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPut = vi.fn()
const mockUpload = vi.fn()
mockNuxtImport('useApi', () => () => ({
get: mockGet,
put: mockPut,
upload: mockUpload,
}))
const mockSetUser = vi.fn()
mockNuxtImport('useAuthStore', () => () => ({
user: { id: 1, display_name: 'Test', account_type: 'personal' },
setUser: mockSetUser,
}))
const { useProfileForm } = await import('./useProfileForm')
describe('useProfileForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns expected composable shape', () => {
const result = useProfileForm()
expect(result).toHaveProperty('form')
expect(result).toHaveProperty('saving')
expect(result).toHaveProperty('loadProfile')
expect(result).toHaveProperty('saveProfile')
expect(result).toHaveProperty('uploadAvatar')
})
it('loadProfile populates form from API', async () => {
mockGet.mockResolvedValue({
data: {
id: 1,
display_name: 'Иван',
phone: '+79001234567',
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.phone).toBe('+79001234567')
expect(form.account_type).toBe('personal')
})
it('saveProfile calls PUT /vendor/me and updates auth store', async () => {
const updatedUser = { id: 1, display_name: 'Обновлённый', account_type: 'personal' }
mockPut.mockResolvedValue({ data: updatedUser })
const { form, saveProfile } = useProfileForm()
form.display_name = 'Обновлённый'
const success = await saveProfile()
expect(success).toBe(true)
expect(mockPut).toHaveBeenCalledWith('/vendor/me', expect.objectContaining({
display_name: 'Обновлённый',
}))
expect(mockSetUser).toHaveBeenCalledWith(updatedUser)
})
it('uploadAvatar calls upload and updates auth store', async () => {
const updatedUser = { id: 1, avatar_url: '/new-avatar.jpg' }
mockUpload.mockResolvedValue({ data: updatedUser })
const { uploadAvatar } = useProfileForm()
const file = new File([''], 'avatar.jpg', { type: 'image/jpeg' })
const success = await uploadAvatar(file)
expect(success).toBe(true)
expect(mockUpload).toHaveBeenCalled()
expect(mockSetUser).toHaveBeenCalledWith(updatedUser)
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/features/cabinet-settings/composables/useProfileForm.test.ts Expected: FAIL — module not found
Step 3: Write implementation
Create app/features/cabinet-settings/composables/useProfileForm.ts:
typescript
import { reactive, ref, readonly } from 'vue'
import type { ProfileForm } from '~/shared/schemas/settings'
import type { User } from '~/entities/user/model/user.schema'
import type { ApiItemResponse } from '~/shared/api/types'
export function useProfileForm() {
const api = useApi()
const authStore = useAuthStore()
const form = reactive<ProfileForm>({
display_name: '',
phone: null,
account_type: 'personal',
city_id: null,
district_id: null,
metro_station_id: null,
})
const saving = ref(false)
const uploading = ref(false)
const error = ref<string | null>(null)
const fieldErrors = ref<Record<string, string>>({})
async function loadProfile() {
const response = await api.get<ApiItemResponse<User>>('/vendor/me')
const u = response.data
form.display_name = u.display_name
form.phone = u.phone
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
}
async function saveProfile(): Promise<boolean> {
saving.value = true
error.value = null
fieldErrors.value = {}
try {
const response = await api.put<ApiItemResponse<User>>('/vendor/me', {
display_name: form.display_name,
phone: form.phone,
account_type: form.account_type,
city_id: form.city_id,
district_id: form.district_id,
metro_station_id: form.metro_station_id,
})
authStore.setUser(response.data)
return true
} catch (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 false
} finally {
saving.value = false
}
}
async function uploadAvatar(file: File): Promise<boolean> {
uploading.value = true
error.value = null
try {
const formData = new FormData()
formData.append('avatar', file)
const response = await api.upload<ApiItemResponse<User>>('/vendor/me/avatar', formData)
authStore.setUser(response.data)
return true
} catch {
error.value = 'Ошибка загрузки аватара'
return false
} finally {
uploading.value = false
}
}
return {
form,
saving: readonly(saving),
uploading: readonly(uploading),
error: readonly(error),
fieldErrors: readonly(fieldErrors),
loadProfile,
saveProfile,
uploadAvatar,
}
}Step 4: Run test to verify it passes
Run: npx vitest run app/features/cabinet-settings/composables/useProfileForm.test.ts Expected: PASS
Step 5: Commit
bash
git add app/features/cabinet-settings/composables/useProfileForm.ts app/features/cabinet-settings/composables/useProfileForm.test.ts
git commit -m "feat(settings): add useProfileForm composable with API integration"Task 9: Profile Settings Page
Files:
- Create:
app/pages/cabinet/settings/profile.vue
Step 1: Create profile page
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>
<!-- Phone -->
<UFormField :label="t('settings.phone')">
<UInput v-model="form.phone" :placeholder="t('settings.phonePlaceholder')" class="w-full" />
</UFormField>
<!-- 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 -->
<GeoSelectGeoSelect @change="(sel) => {
form.city_id = sel.city_id
form.district_id = sel.district_id
form.metro_station_id = sel.metro_station_id
}" />
<!-- 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: Verify
Run dev server, navigate to /cabinet/settings/profile. Check form loads, avatar upload works, save works.
Step 3: Commit
bash
git add app/pages/cabinet/settings/profile.vue
git commit -m "feat(settings): implement profile settings page with avatar, geo, account type"Task 10: Security Form Composable
Files:
- Create:
app/features/cabinet-settings/composables/useSecurityForm.ts - Test:
app/features/cabinet-settings/composables/useSecurityForm.test.ts
Step 1: Write the failing test
Create app/features/cabinet-settings/composables/useSecurityForm.test.ts:
typescript
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()
const mockPost = vi.fn()
mockNuxtImport('useApi', () => () => ({
get: mockGet,
put: mockPut,
delete: mockDelete,
post: mockPost,
}))
const { useSecurityForm } = await import('./useSecurityForm')
describe('useSecurityForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns expected composable shape', () => {
const result = useSecurityForm()
expect(result).toHaveProperty('passwordForm')
expect(result).toHaveProperty('sessions')
expect(result).toHaveProperty('changePassword')
expect(result).toHaveProperty('fetchSessions')
expect(result).toHaveProperty('endSession')
expect(result).toHaveProperty('endAllSessions')
})
it('changePassword calls PUT /vendor/me with password fields', async () => {
mockPut.mockResolvedValue({ data: {} })
const { passwordForm, changePassword } = useSecurityForm()
passwordForm.current_password = 'OldPass1!'
passwordForm.password = 'NewPass1!'
passwordForm.password_confirmation = 'NewPass1!'
const success = await changePassword()
expect(success).toBe(true)
expect(mockPut).toHaveBeenCalledWith('/vendor/me', {
current_password: 'OldPass1!',
password: 'NewPass1!',
})
})
it('fetchSessions loads sessions from API', async () => {
mockGet.mockResolvedValue({
data: [
{ id: 1, ip_address: '127.0.0.1', is_current: true },
{ id: 2, ip_address: '192.168.1.1', is_current: false },
],
})
const { sessions, fetchSessions } = useSecurityForm()
await fetchSessions()
expect(mockGet).toHaveBeenCalledWith('/vendor/sessions')
expect(sessions.value).toHaveLength(2)
})
it('endSession calls DELETE and removes from list', async () => {
mockGet.mockResolvedValue({
data: [
{ id: 1, is_current: true },
{ id: 2, is_current: false },
],
})
mockDelete.mockResolvedValue({})
const { sessions, fetchSessions, endSession } = useSecurityForm()
await fetchSessions()
await endSession(2)
expect(mockDelete).toHaveBeenCalledWith('/vendor/sessions/2')
expect(sessions.value).toHaveLength(1)
})
it('endAllSessions calls POST /auth/logout-all', async () => {
mockPost.mockResolvedValue({})
mockGet.mockResolvedValue({ data: [{ id: 1, is_current: true }] })
const { endAllSessions } = useSecurityForm()
await endAllSessions()
expect(mockPost).toHaveBeenCalledWith('/auth/logout-all')
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/features/cabinet-settings/composables/useSecurityForm.test.ts Expected: FAIL
Step 3: Write implementation
Create app/features/cabinet-settings/composables/useSecurityForm.ts:
typescript
import { reactive, ref, readonly } from 'vue'
import type { ApiItemResponse, ApiListResponse } from '~/shared/api/types'
interface Session {
id: number
ip_address: string
user_agent: string | null
last_activity: string
is_current: boolean
}
export function useSecurityForm() {
const api = useApi()
const passwordForm = reactive({
current_password: '',
password: '',
password_confirmation: '',
})
const saving = ref(false)
const error = ref<string | null>(null)
const fieldErrors = ref<Record<string, string>>({})
const sessions = ref<Session[]>([])
const loadingSessions = ref(false)
async function changePassword(): Promise<boolean> {
saving.value = true
error.value = null
fieldErrors.value = {}
try {
await api.put<ApiItemResponse<unknown>>('/vendor/me', {
current_password: passwordForm.current_password,
password: passwordForm.password,
})
passwordForm.current_password = ''
passwordForm.password = ''
passwordForm.password_confirmation = ''
return true
} catch (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 false
} finally {
saving.value = false
}
}
async function fetchSessions() {
loadingSessions.value = true
try {
const response = await api.get<ApiListResponse<Session>>('/vendor/sessions')
sessions.value = response.data
} catch {
sessions.value = []
} finally {
loadingSessions.value = false
}
}
async function endSession(sessionId: number) {
await api.delete(`/vendor/sessions/${sessionId}`)
sessions.value = sessions.value.filter(s => s.id !== sessionId)
}
async function endAllSessions() {
await api.post('/auth/logout-all')
await fetchSessions()
}
return {
passwordForm,
saving: readonly(saving),
error: readonly(error),
fieldErrors: readonly(fieldErrors),
sessions,
loadingSessions: readonly(loadingSessions),
changePassword,
fetchSessions,
endSession,
endAllSessions,
}
}Step 4: Run test to verify it passes
Run: npx vitest run app/features/cabinet-settings/composables/useSecurityForm.test.ts Expected: PASS
Step 5: Commit
bash
git add app/features/cabinet-settings/composables/useSecurityForm.ts app/features/cabinet-settings/composables/useSecurityForm.test.ts
git commit -m "feat(settings): add useSecurityForm composable for password change and sessions"Task 11: Security Settings Page
Files:
- Create:
app/pages/cabinet/settings/security.vue
Step 1: Create security page
html
<script setup lang="ts">
import { changePasswordSchema } from '~/shared/schemas/settings'
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Безопасность', robots: 'noindex, nofollow' })
const { t } = useI18n()
const toast = useToast()
const {
passwordForm, saving, error, fieldErrors, sessions, loadingSessions,
changePassword, fetchSessions, endSession, endAllSessions,
} = useSecurityForm()
onMounted(() => {
fetchSessions()
})
async function handleChangePassword() {
const result = changePasswordSchema.safeParse(passwordForm)
if (!result.success) {
const firstError = result.error.errors[0]
toast.add({ title: firstError.message, color: 'error' })
return
}
const success = await changePassword()
if (success) {
toast.add({ title: t('settings.passwordChanged'), color: 'success' })
}
}
async function handleEndSession(id: number) {
await endSession(id)
toast.add({ title: t('settings.sessionEnded'), color: 'success' })
}
async function handleEndAllSessions() {
await endAllSessions()
toast.add({ title: t('settings.allSessionsEnded'), 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-8">
<!-- Password change -->
<section>
<h2 class="text-lg font-semibold mb-4">{{ t('settings.changePassword') }}</h2>
<UAlert v-if="error" color="error" :title="error" class="mb-4" />
<div class="space-y-4">
<UFormField :label="t('settings.currentPassword')" :error="fieldErrors.current_password">
<UInput v-model="passwordForm.current_password" type="password" class="w-full" />
</UFormField>
<UFormField :label="t('settings.newPassword')" :error="fieldErrors.password">
<UInput v-model="passwordForm.password" type="password" :placeholder="t('auth.passwordHint')" class="w-full" />
</UFormField>
<UFormField :label="t('settings.confirmPassword')" :error="fieldErrors.password_confirmation">
<UInput v-model="passwordForm.password_confirmation" type="password" class="w-full" />
</UFormField>
<UButton color="primary" :loading="saving" @click="handleChangePassword">
{{ t('settings.changePassword') }}
</UButton>
</div>
</section>
<!-- Sessions -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ t('settings.sessions') }}</h2>
<UButton
v-if="sessions.length > 1"
variant="outline"
size="sm"
color="error"
@click="handleEndAllSessions"
>
{{ t('settings.endAllSessions') }}
</UButton>
</div>
<div v-if="loadingSessions" class="py-4 text-center">
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
</div>
<div v-else class="space-y-3">
<div
v-for="session in sessions"
:key="session.id"
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-gray-700"
>
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ session.ip_address }}</span>
<UBadge v-if="session.is_current" color="success" variant="subtle" size="xs">
{{ t('settings.currentSession') }}
</UBadge>
</div>
<div v-if="session.user_agent" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ session.user_agent }}
</div>
</div>
<UButton
v-if="!session.is_current"
variant="ghost"
size="xs"
color="error"
@click="handleEndSession(session.id)"
>
{{ t('settings.endSession') }}
</UButton>
</div>
</div>
</section>
</div>
</div>
</template>Step 2: Verify
Run dev server, navigate to /cabinet/settings/security.
Step 3: Commit
bash
git add app/pages/cabinet/settings/security.vue
git commit -m "feat(settings): implement security page with password change and sessions"Task 12: Business Form Composable
Files:
- Create:
app/features/cabinet-settings/composables/useBusinessForm.ts - Test:
app/features/cabinet-settings/composables/useBusinessForm.test.ts
Step 1: Write the failing test
Create app/features/cabinet-settings/composables/useBusinessForm.test.ts:
typescript
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockGet = vi.fn()
const mockPut = vi.fn()
mockNuxtImport('useApi', () => () => ({
get: mockGet,
put: mockPut,
}))
mockNuxtImport('useAuthStore', () => () => ({
user: { id: 1, account_type: 'business' },
}))
const { useBusinessForm } = await import('./useBusinessForm')
describe('useBusinessForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns expected composable shape', () => {
const result = useBusinessForm()
expect(result).toHaveProperty('form')
expect(result).toHaveProperty('saving')
expect(result).toHaveProperty('loadBusiness')
expect(result).toHaveProperty('saveBusiness')
})
it('loadBusiness populates form from vendor/me response', async () => {
mockGet.mockResolvedValue({
data: {
id: 1,
business_profile: {
id: 1,
company_name: 'ООО Запчасти',
inn: '1234567890',
address: 'ул. Ленина 1',
},
},
})
const { form, loadBusiness } = useBusinessForm()
await loadBusiness()
expect(form.company_name).toBe('ООО Запчасти')
expect(form.inn).toBe('1234567890')
expect(form.address).toBe('ул. Ленина 1')
})
it('saveBusiness calls PUT /vendor/me with business_profile', async () => {
mockPut.mockResolvedValue({ data: {} })
const { form, saveBusiness } = useBusinessForm()
form.company_name = 'Новая компания'
form.inn = '1234567890'
const success = await saveBusiness()
expect(success).toBe(true)
expect(mockPut).toHaveBeenCalledWith('/vendor/me', {
business_profile: {
company_name: 'Новая компания',
inn: '1234567890',
address: null,
},
})
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/features/cabinet-settings/composables/useBusinessForm.test.ts
Step 3: Write implementation
Create app/features/cabinet-settings/composables/useBusinessForm.ts:
typescript
import { reactive, ref, readonly } from 'vue'
import type { BusinessForm } from '~/shared/schemas/settings'
import type { ApiItemResponse } from '~/shared/api/types'
interface VendorProfile {
id: number
business_profile?: {
id: number
company_name: string
inn: string | null
address: string | null
}
}
export function useBusinessForm() {
const api = useApi()
const form = reactive<BusinessForm>({
company_name: '',
inn: null,
address: null,
})
const saving = ref(false)
const error = ref<string | null>(null)
const fieldErrors = ref<Record<string, string>>({})
async function loadBusiness() {
const response = await api.get<ApiItemResponse<VendorProfile>>('/vendor/me')
const bp = response.data.business_profile
if (bp) {
form.company_name = bp.company_name
form.inn = bp.inn
form.address = bp.address
}
}
async function saveBusiness(): Promise<boolean> {
saving.value = true
error.value = null
fieldErrors.value = {}
try {
await api.put('/vendor/me', {
business_profile: {
company_name: form.company_name,
inn: form.inn,
address: form.address,
},
})
return true
} catch (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 false
} finally {
saving.value = false
}
}
return {
form,
saving: readonly(saving),
error: readonly(error),
fieldErrors: readonly(fieldErrors),
loadBusiness,
saveBusiness,
}
}Step 4: Run test to verify it passes
Run: npx vitest run app/features/cabinet-settings/composables/useBusinessForm.test.ts Expected: PASS
Step 5: Commit
bash
git add app/features/cabinet-settings/composables/useBusinessForm.ts app/features/cabinet-settings/composables/useBusinessForm.test.ts
git commit -m "feat(settings): add useBusinessForm composable for business profile"Task 13: Business Settings Page
Files:
- Create:
app/pages/cabinet/settings/business.vue
Step 1: Create business page
html
<script setup lang="ts">
import { businessFormSchema } from '~/shared/schemas/settings'
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Бизнес-профиль', robots: 'noindex, nofollow' })
const { t } = useI18n()
const toast = useToast()
const authStore = useAuthStore()
// Redirect if not business account
if (authStore.user?.account_type !== 'business') {
await navigateTo('/cabinet/settings/profile', { replace: true })
}
const { form, saving, error, fieldErrors, loadBusiness, saveBusiness } = useBusinessForm()
onMounted(async () => {
await loadBusiness()
})
async function handleSave() {
const result = businessFormSchema.safeParse(form)
if (!result.success) {
const firstError = result.error.errors[0]
toast.add({ title: firstError.message, color: 'error' })
return
}
const success = await saveBusiness()
if (success) {
toast.add({ title: t('settings.businessSaved'), 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">
<UAlert v-if="error" color="error" :title="error" />
<UFormField :label="t('settings.companyName')" :error="fieldErrors.company_name">
<UInput v-model="form.company_name" class="w-full" />
</UFormField>
<UFormField :label="t('settings.inn')" :error="fieldErrors.inn">
<UInput v-model="form.inn" :placeholder="t('settings.innPlaceholder')" class="w-full" />
</UFormField>
<UFormField :label="t('settings.companyAddress')" :error="fieldErrors.address">
<UInput v-model="form.address" class="w-full" />
</UFormField>
<UButton color="primary" :loading="saving" @click="handleSave">
{{ t('common.save') }}
</UButton>
</div>
</div>
</template>Step 2: Verify
Run dev server. Switch account to business on profile page. Navigate to /cabinet/settings/business.
Step 3: Commit
bash
git add app/pages/cabinet/settings/business.vue
git commit -m "feat(settings): implement business profile settings page"Task 14: Update CLAUDE.md
Files:
- Modify:
CLAUDE.md
Step 1: Update progress section
Add to the Progress checklist in CLAUDE.md:
- [x] Cabinet favorites (API-backed store, FavoriteButton, favorites page)
- [x] Cabinet settings (profile, security, business profile subpages)Step 2: Update file structure
Add new files to the project structure section where relevant:
- Under
features/:cabinet-settings/,favorites/ - Under
pages/cabinet/:settings/profile.vue,settings/security.vue,settings/business.vue
Step 3: Commit
bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with cabinet favorites and settings"Task 15: Final Verification
Step 1: Run all tests
bash
npx vitest runExpected: All tests pass.
Step 2: Run linter
bash
npm run lintExpected: No errors (fix any FSD import violations).
Step 3: Run typecheck
bash
npm run typecheckExpected: No TypeScript errors.
Step 4: Verify in browser
Manual checklist:
- [ ] Catalog: heart icon on cards (logged in only)
- [ ] Product detail: heart next to price (logged in only)
- [ ] Toggle favorite: instant UI update, API call
- [ ]
/cabinet/favorites: shows favorited products, empty state works - [ ] Unfavorite on favorites page: card disappears
- [ ]
/cabinet/settings/profile: loads data, save works, avatar upload works - [ ] Account type switch: business tab appears/disappears
- [ ]
/cabinet/settings/security: password change, sessions list, end session - [ ]
/cabinet/settings/business: loads/saves business data, redirects if personal account - [ ] Guest: no heart icons, cabinet requires login
Step 5: Commit any fixes
bash
git add -A
git commit -m "fix: final adjustments after verification"