Skip to content

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.vueFavoritesFavoriteButton.

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">&#123;&#123; 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">&#123;&#123; t('favorites.title') }}</h1>
      <p v-if="favoritesStore.count > 0" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
        &#123;&#123; 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">&#123;&#123; t('favorites.empty') }}</p>
      <UButton color="primary" to="/catalog">
        &#123;&#123; 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'"
      >
        &#123;&#123; 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">&#123;&#123; t('common.settings') }}</h1>
    <CabinetSettingsSettingsTabs />

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

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

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

      <!-- 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">&#123;&#123; t('settings.appearance') }}</h2>
        <div class="flex items-center gap-4">
          <label class="text-sm font-medium">&#123;&#123; t('settings.theme') }}</label>
          <UColorModeSelect />
        </div>
      </div>

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

Step 2: 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">&#123;&#123; t('common.settings') }}</h1>
    <CabinetSettingsSettingsTabs />

    <div class="max-w-lg space-y-8">
      <!-- Password change -->
      <section>
        <h2 class="text-lg font-semibold mb-4">&#123;&#123; 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">
            &#123;&#123; t('settings.changePassword') }}
          </UButton>
        </div>
      </section>

      <!-- Sessions -->
      <section>
        <div class="flex items-center justify-between mb-4">
          <h2 class="text-lg font-semibold">&#123;&#123; t('settings.sessions') }}</h2>
          <UButton
            v-if="sessions.length > 1"
            variant="outline"
            size="sm"
            color="error"
            @click="handleEndAllSessions"
          >
            &#123;&#123; 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">&#123;&#123; session.ip_address }}</span>
                <UBadge v-if="session.is_current" color="success" variant="subtle" size="xs">
                  &#123;&#123; t('settings.currentSession') }}
                </UBadge>
              </div>
              <div v-if="session.user_agent" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
                &#123;&#123; session.user_agent }}
              </div>
            </div>
            <UButton
              v-if="!session.is_current"
              variant="ghost"
              size="xs"
              color="error"
              @click="handleEndSession(session.id)"
            >
              &#123;&#123; 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">&#123;&#123; 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">
        &#123;&#123; 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 run

Expected: All tests pass.

Step 2: Run linter

bash
npm run lint

Expected: No errors (fix any FSD import violations).

Step 3: Run typecheck

bash
npm run typecheck

Expected: 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"