Skip to content

Listings CRUD Implementation Plan

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

Goal: Full end-to-end listings flow — cabinet CRUD with photo upload + public catalog + product detail page.

Architecture: FSD layers. Features (product-form, image-upload, ymm-select, geo-select) as composables + UI. Existing useApi() + useCursorPagination() for data fetching. Nuxt UI v3 components for all UI. Zod schemas for validation.

Tech Stack: Nuxt 4.3, Vue 3.5 Composition API, Nuxt UI v3, Pinia, Zod, Tailwind CSS 4, Vitest


Task 1: Extend Product Schemas

Files:

  • Modify: app/entities/product/model/product.schema.ts
  • Test: tests/entities/product/model/product.schema.test.ts

Step 1: Write the failing test

ts
// tests/entities/product/model/product.schema.test.ts
import { describe, it, expect } from 'vitest'
import {
  productImageSchema,
  compatibilitySchema,
  productOemSchema,
  productDetailSchema,
  productFormSchema,
} from '~/entities/product/model/product.schema'

describe('product schemas', () => {
  it('validates product image', () => {
    const image = {
      id: 1,
      thumbnail_webp: 'https://s3.example.com/thumb.webp',
      thumbnail_jpeg: 'https://s3.example.com/thumb.jpg',
      medium_webp: 'https://s3.example.com/med.webp',
      medium_jpeg: 'https://s3.example.com/med.jpg',
      large_webp: 'https://s3.example.com/lg.webp',
      large_jpeg: 'https://s3.example.com/lg.jpg',
      status: 'ready' as const,
      is_primary: true,
      sort_order: 0,
    }
    expect(productImageSchema.parse(image)).toEqual(image)
  })

  it('rejects invalid image status', () => {
    expect(() =>
      productImageSchema.parse({ id: 1, status: 'unknown', is_primary: false, sort_order: 0 }),
    ).toThrow()
  })

  it('validates compatibility entry', () => {
    const compat = { make_id: 1, model_id: 10, generation_id: 45, note: 'Fits 2015-2019' }
    expect(compatibilitySchema.parse(compat)).toEqual(compat)
  })

  it('validates compatibility with nulls', () => {
    const compat = { make_id: 1, model_id: null, generation_id: null, note: null }
    expect(compatibilitySchema.parse(compat)).toEqual(compat)
  })

  it('validates product OEM entry', () => {
    const oem = { oem_number_id: 5, is_primary: true }
    expect(productOemSchema.parse(oem)).toEqual(oem)
  })

  it('validates full product detail', () => {
    const detail = {
      id: 1,
      title: 'Фара левая BMW E46',
      description: 'В хорошем состоянии',
      price: 5000,
      steering: 'left',
      oem_number: '63126904307',
      manufacturer: 'BMW',
      status: 'active',
      views_count: 42,
      favorites_count: 3,
      created_at: '2026-02-01T10:00:00+00:00',
      updated_at: '2026-02-01T10:00:00+00:00',
      published_at: '2026-02-01T10:30:00+00:00',
      seller_id: 5,
      city_id: 1,
      region_id: 47,
      district_id: null,
      metro_station_id: null,
      address: null,
      primary_category_id: 10,
      is_available: true,
      images: [],
      compatibility: [],
      oem_numbers: [],
      categories: [],
      seller: {
        id: 5,
        display_name: 'BMW Parts Store',
        avatar_url: null,
        city_id: 1,
        rating: '4.85',
        reviews_count: 127,
        products_count: 342,
        created_at: '2025-06-15T08:00:00+00:00',
      },
    }
    expect(() => productDetailSchema.parse(detail)).not.toThrow()
  })

  it('validates product form data', () => {
    const form = {
      title: 'Фара левая BMW E46',
      description: 'В хорошем состоянии',
      price: 5000,
      steering: 'left',
      oem_number: '63126904307',
      manufacturer: 'BMW',
      city_id: 1,
      category_ids: [{ category_id: 10, is_primary: true }],
      condition_category_id: 21,
      compatibility: [{ make_id: 1, model_id: 10, generation_id: 45, note: null }],
    }
    expect(() => productFormSchema.parse(form)).not.toThrow()
  })

  it('rejects form without title', () => {
    expect(() =>
      productFormSchema.parse({ price: 5000, city_id: 1, category_ids: [{ category_id: 1, is_primary: true }], condition_category_id: 1 }),
    ).toThrow()
  })

  it('rejects form with empty category_ids', () => {
    expect(() =>
      productFormSchema.parse({ title: 'Test', price: 5000, city_id: 1, category_ids: [], condition_category_id: 1 }),
    ).toThrow()
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run tests/entities/product/model/product.schema.test.ts Expected: FAIL — productImageSchema, productDetailSchema, etc. not exported

Step 3: Write implementation

ts
// app/entities/product/model/product.schema.ts
import { z } from 'zod'

// --- Enums ---

export const productStatusSchema = z.enum([
  'draft',
  'pending',
  'active',
  'sold',
  'archived',
  'rejected',
])

export const steeringSchema = z.enum(['left', 'right', 'universal'])

export const imageStatusSchema = z.enum(['processing', 'ready', 'error'])

// --- Sub-schemas ---

export const productImageSchema = z.object({
  id: z.number(),
  thumbnail_webp: z.string().nullable().optional(),
  thumbnail_jpeg: z.string().nullable().optional(),
  medium_webp: z.string().nullable().optional(),
  medium_jpeg: z.string().nullable().optional(),
  large_webp: z.string().nullable().optional(),
  large_jpeg: z.string().nullable().optional(),
  status: imageStatusSchema,
  is_primary: z.boolean(),
  sort_order: z.number(),
})

export const compatibilitySchema = z.object({
  make_id: z.number(),
  model_id: z.number().nullable(),
  generation_id: z.number().nullable(),
  note: z.string().nullable(),
})

export const productOemSchema = z.object({
  oem_number_id: z.number(),
  is_primary: z.boolean(),
})

export const productCategorySchema = z.object({
  category_id: z.number(),
  is_primary: z.boolean(),
})

export const sellerPublicSchema = z.object({
  id: z.number(),
  display_name: z.string(),
  avatar_url: z.string().nullable(),
  city_id: z.number().nullable().optional(),
  rating: z.string(),
  reviews_count: z.number(),
  products_count: z.number(),
  created_at: z.string(),
})

// --- Product schemas ---

export const productSchema = z.object({
  id: z.number().int().positive(),
  title: z.string().min(3).max(255),
  description: z.string().max(10000).nullable(),
  price: z.number().positive().max(99999999.99),
  steering: steeringSchema,
  oem_number: z.string().max(50).nullable(),
  manufacturer: z.string().max(100).nullable(),
  status: productStatusSchema,
  views_count: z.number().int(),
  favorites_count: z.number().int(),
  created_at: z.string(),
  updated_at: z.string(),
  published_at: z.string().nullable(),
})

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(),
  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,
})

// --- Form validation schema ---

export const productFormSchema = z.object({
  title: z.string().min(3, 'Минимум 3 символа').max(255),
  description: z.string().max(10000).optional(),
  price: z.number().positive('Цена должна быть больше 0').max(99999999.99),
  steering: steeringSchema.default('universal'),
  oem_number: z.string().max(50).optional(),
  manufacturer: z.string().max(100).optional(),
  city_id: z.number().positive(),
  district_id: z.number().positive().optional(),
  metro_station_id: z.number().positive().optional(),
  address: z.string().max(255).optional(),
  category_ids: z.array(productCategorySchema).min(1, 'Выберите хотя бы одну категорию'),
  condition_category_id: z.number().positive('Выберите состояние'),
  compatibility: z.array(compatibilitySchema).default([]),
})

// --- Types ---

export type Product = z.infer<typeof productSchema>
export type ProductDetail = z.infer<typeof productDetailSchema>
export type ProductImage = z.infer<typeof productImageSchema>
export type ProductStatus = z.infer<typeof productStatusSchema>
export type Steering = z.infer<typeof steeringSchema>
export type Compatibility = z.infer<typeof compatibilitySchema>
export type ProductOem = z.infer<typeof productOemSchema>
export type ProductCategory = z.infer<typeof productCategorySchema>
export type SellerPublic = z.infer<typeof sellerPublicSchema>
export type ProductForm = z.infer<typeof productFormSchema>

Step 4: Run test to verify it passes

Run: npx vitest run tests/entities/product/model/product.schema.test.ts Expected: PASS — all 8 tests

Step 5: Commit

bash
git add app/entities/product/model/product.schema.ts tests/entities/product/model/product.schema.test.ts
git commit -m "feat(product): extend product schemas with detail, image, form types"

Task 2: Add upload() Method to API Client

Files:

  • Modify: app/shared/api/client.ts
  • Test: tests/shared/api/client.test.ts

Step 1: Write the failing test

ts
// tests/shared/api/client.test.ts
import { describe, it, expect } from 'vitest'
import type { ApiListResponse, ApiError } from '~/shared/api/types'

describe('API types', () => {
  it('ApiListResponse type is correctly structured', () => {
    const response: ApiListResponse<{ id: number }> = {
      data: [{ id: 1 }],
      meta: { has_more: false, next_cursor: null },
    }
    expect(response.data).toHaveLength(1)
    expect(response.meta.has_more).toBe(false)
  })

  it('ApiError type includes details for validation errors', () => {
    const error: ApiError = {
      error: {
        code: 'validation_error',
        message: 'Validation failed',
        details: { title: ['Title is required'] },
      },
    }
    expect(error.error.details?.title).toEqual(['Title is required'])
  })
})

Note: The API client uses $fetch which is a Nuxt auto-import. Unit testing the client itself requires Nuxt test context which is complex. We test the types and verify the upload method exists structurally. The real test is integration in Task 5 (image upload feature).

Step 2: Run test to verify it passes (existing test adapted)

Run: npx vitest run tests/shared/api/client.test.ts Expected: PASS

Step 3: Add upload method to client.ts

Add to the return object in useApiClient(), after the delete method:

ts
    upload: <T>(url: string, formData: FormData) =>
      request<T>(url, {
        method: 'POST',
        body: formData,
        // Don't set Content-Type — browser sets multipart boundary automatically
      }),

Also update the request function to NOT set Accept: application/json when body is FormData, and skip Content-Type header setting. Modify the headers section:

ts
    const headers: Record<string, string> = {
      ...(fetchOptions.body instanceof FormData ? {} : { Accept: 'application/json' }),
      ...(fetchOptions.headers as Record<string, string>),
    }

Step 4: Verify linting

Run: npm run lint Expected: No new errors

Step 5: Commit

bash
git add app/shared/api/client.ts tests/shared/api/client.test.ts
git commit -m "feat(api): add upload() method for multipart form-data"

Task 3: i18n Keys for Listings

Files:

  • Modify: i18n/locales/ru.json

Step 1: Add all new translation keys

Add the following sections to ru.json:

json
{
  "listing": {
    "newListing": "Новое объявление",
    "editListing": "Редактирование объявления",
    "category": "Категория",
    "selectCategory": "Выберите категорию",
    "subcategory": "Подкатегория",
    "selectSubcategory": "Выберите подкатегорию",
    "title": "Название",
    "titlePlaceholder": "Например: Фара левая BMW E46",
    "photos": "Фотографии",
    "mainPhoto": "Главное фото",
    "addPhoto": "Добавить фото",
    "maxPhotos": "Максимум {max} фотографий",
    "photoProcessing": "Обработка...",
    "photoError": "Ошибка загрузки",
    "vehicleCompat": "Подходит для авто",
    "addVehicle": "+ Добавить ещё авто",
    "specs": "Характеристики",
    "oemNumber": "OEM номер",
    "oemPlaceholder": "Например: 63126904307",
    "conditionLabel": "Состояние",
    "manufacturer": "Производитель",
    "manufacturerPlaceholder": "Например: BMW Original",
    "steering": "Тип руля",
    "steeringLeft": "Левый",
    "steeringRight": "Правый",
    "steeringUniversal": "Универсальный",
    "description": "Описание",
    "descriptionPlaceholder": "Опишите состояние детали, особенности...",
    "pricePlaceholder": "0",
    "currency": "₽",
    "location": "Местоположение",
    "region": "Регион",
    "selectRegion": "Выберите регион",
    "city": "Город",
    "selectCity": "Выберите город",
    "district": "Район",
    "selectDistrict": "Выберите район",
    "metro": "Метро",
    "selectMetro": "Выберите станцию",
    "address": "Адрес",
    "addressPlaceholder": "Адрес (необязательно)",
    "publish": "Опубликовать",
    "saveDraft": "Сохранить черновик",
    "saving": "Сохранение...",
    "publishing": "Публикация...",
    "draftSaved": "Черновик сохранён",
    "publishedSuccess": "Объявление отправлено на модерацию",
    "deleteConfirm": "Удалить объявление?",
    "deleteSuccess": "Объявление удалено"
  },
  "myListings": {
    "title": "Мои объявления",
    "empty": "У вас пока нет объявлений",
    "createFirst": "Создать первое объявление",
    "all": "Все",
    "active": "Активные",
    "draft": "Черновики",
    "pending": "На модерации",
    "sold": "Проданные",
    "archived": "В архиве",
    "rejected": "Отклонённые",
    "statusDraft": "Черновик",
    "statusPending": "На модерации",
    "statusActive": "Активно",
    "statusSold": "Продано",
    "statusArchived": "В архиве",
    "statusRejected": "Отклонено"
  },
  "productDetail": {
    "characteristics": "Характеристики",
    "fitsFor": "Подходит для",
    "description": "Описание",
    "seller": "Продавец",
    "contactSeller": "Написать продавцу",
    "allSellerProducts": "Все объявления продавца",
    "memberSince": "На сайте с {date}",
    "notFound": "Объявление не найдено",
    "notFoundDescription": "Возможно, оно было удалено или снято с публикации"
  },
  "geo": {
    "region": "Регион",
    "city": "Город",
    "district": "Район",
    "metro": "Метро",
    "selectRegion": "Выберите регион",
    "selectCity": "Выберите город",
    "selectDistrict": "Выберите район",
    "selectMetro": "Выберите станцию"
  }
}

Step 2: Verify JSON validity

Run: node -e "JSON.parse(require('fs').readFileSync('i18n/locales/ru.json','utf8')); console.log('Valid JSON')" Expected: "Valid JSON"

Step 3: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(i18n): add translation keys for listings, catalog, product detail"

Task 4: YMM Cascading Select

Files:

  • Create: app/features/ymm-select/composables/useYmmCascade.ts
  • Create: app/features/ymm-select/ui/YmmSelect.vue
  • Create: app/features/ymm-select/ui/YmmMultiSelect.vue
  • Test: tests/features/ymm-select/composables/useYmmCascade.test.ts

Step 1: Write the failing test

ts
// tests/features/ymm-select/composables/useYmmCascade.test.ts
import { describe, it, expect, vi } from 'vitest'

// We test the cascade logic, not the API calls (those are integration tests)
describe('YMM cascade logic', () => {
  it('exports useYmmCascade composable', async () => {
    // This test verifies the module exists and exports correctly
    const mod = await import('~/features/ymm-select/composables/useYmmCascade')
    expect(mod.useYmmCascade).toBeDefined()
    expect(typeof mod.useYmmCascade).toBe('function')
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run tests/features/ymm-select/composables/useYmmCascade.test.ts Expected: FAIL — module not found

Step 3: Implement composable

ts
// app/features/ymm-select/composables/useYmmCascade.ts
import { ref, watch, readonly } from 'vue'

import type { CarMake, CarModel, CarGeneration } from '~/entities/car/model/car.schema'
import type { ApiListResponse } from '~/shared/api/types'

export interface YmmSelection {
  make_id: number | null
  model_id: number | null
  generation_id: number | null
}

export function useYmmCascade() {
  const api = useApi()

  const makes = ref<CarMake[]>([])
  const models = ref<CarModel[]>([])
  const generations = ref<CarGeneration[]>([])

  const selectedMakeId = ref<number | null>(null)
  const selectedModelId = ref<number | null>(null)
  const selectedGenerationId = ref<number | null>(null)

  const loadingMakes = ref(false)
  const loadingModels = ref(false)
  const loadingGenerations = ref(false)

  async function fetchMakes() {
    loadingMakes.value = true
    try {
      const response = await api.get<ApiListResponse<CarMake>>('/store/cars/makes')
      makes.value = response.data
    } finally {
      loadingMakes.value = false
    }
  }

  async function fetchModels(makeId: number) {
    loadingModels.value = true
    models.value = []
    generations.value = []
    selectedModelId.value = null
    selectedGenerationId.value = null
    try {
      const response = await api.get<ApiListResponse<CarModel>>(`/store/cars/makes/${makeId}/models`)
      models.value = response.data
    } finally {
      loadingModels.value = false
    }
  }

  async function fetchGenerations(modelId: number) {
    loadingGenerations.value = true
    generations.value = []
    selectedGenerationId.value = null
    try {
      const response = await api.get<ApiListResponse<CarGeneration>>(`/store/cars/models/${modelId}/generations`)
      generations.value = response.data
    } finally {
      loadingGenerations.value = false
    }
  }

  watch(selectedMakeId, (makeId) => {
    if (makeId) fetchModels(makeId)
    else {
      models.value = []
      generations.value = []
      selectedModelId.value = null
      selectedGenerationId.value = null
    }
  })

  watch(selectedModelId, (modelId) => {
    if (modelId) fetchGenerations(modelId)
    else {
      generations.value = []
      selectedGenerationId.value = null
    }
  })

  function getSelection(): YmmSelection {
    return {
      make_id: selectedMakeId.value,
      model_id: selectedModelId.value,
      generation_id: selectedGenerationId.value,
    }
  }

  function setSelection(selection: YmmSelection) {
    // Set without triggering cascading fetches — caller must pre-load data
    selectedMakeId.value = selection.make_id
    selectedModelId.value = selection.model_id
    selectedGenerationId.value = selection.generation_id
  }

  function reset() {
    selectedMakeId.value = null
    selectedModelId.value = null
    selectedGenerationId.value = null
    models.value = []
    generations.value = []
  }

  return {
    makes: readonly(makes),
    models: readonly(models),
    generations: readonly(generations),
    selectedMakeId,
    selectedModelId,
    selectedGenerationId,
    loadingMakes: readonly(loadingMakes),
    loadingModels: readonly(loadingModels),
    loadingGenerations: readonly(loadingGenerations),
    fetchMakes,
    getSelection,
    setSelection,
    reset,
  }
}

Step 4: Implement YmmSelect.vue (single row)

html
<!-- app/features/ymm-select/ui/YmmSelect.vue -->
<script setup lang="ts">
import type { YmmSelection } from '../composables/useYmmCascade'

const props = defineProps<{
  makes: { id: number; name: string }[]
  models: { id: number; name: string }[]
  generations: { id: number; name: string; code?: string | null; year_from: number; year_to?: number | null }[]
  loadingMakes?: boolean
  loadingModels?: boolean
  loadingGenerations?: boolean
}>()

const makeId = defineModel<number | null>('makeId', { default: null })
const modelId = defineModel<number | null>('modelId', { default: null })
const generationId = defineModel<number | null>('generationId', { default: null })

const emit = defineEmits<{
  remove: []
}>()

const { t } = useI18n()

const makeOptions = computed(() =>
  props.makes.map(m => ({ label: m.name, value: m.id }))
)
const modelOptions = computed(() =>
  props.models.map(m => ({ label: m.name, value: m.id }))
)
const generationOptions = computed(() =>
  props.generations.map(g => ({
    label: `${g.name}${g.code ? ` (${g.code})` : ''} ${g.year_from}–${g.year_to ?? '...'}`,
    value: g.id,
  }))
)
</script>

<template>
  <div class="flex flex-col sm:flex-row gap-2 items-start">
    <USelectMenu
      v-model="makeId"
      :options="makeOptions"
      :placeholder="t('ymm.selectMake')"
      :loading="loadingMakes"
      value-attribute="value"
      option-attribute="label"
      class="flex-1"
      searchable
    />
    <USelectMenu
      v-model="modelId"
      :options="modelOptions"
      :placeholder="t('ymm.selectModel')"
      :loading="loadingModels"
      :disabled="!makeId"
      value-attribute="value"
      option-attribute="label"
      class="flex-1"
      searchable
    />
    <USelectMenu
      v-model="generationId"
      :options="generationOptions"
      :placeholder="t('ymm.selectGeneration')"
      :loading="loadingGenerations"
      :disabled="!modelId"
      value-attribute="value"
      option-attribute="label"
      class="flex-1"
      searchable
    />
    <UButton
      icon="i-heroicons-x-mark"
      variant="ghost"
      color="neutral"
      size="sm"
      @click="emit('remove')"
    />
  </div>
</template>

Step 5: Implement YmmMultiSelect.vue (multiple rows)

html
<!-- app/features/ymm-select/ui/YmmMultiSelect.vue -->
<script setup lang="ts">
import type { Compatibility } from '~/entities/product/model/product.schema'

const model = defineModel<Compatibility[]>({ default: () => [] })

const { t } = useI18n()

// Each row has its own cascade instance
const rows = ref<Array<ReturnType<typeof useYmmCascade>>>([])

function addRow() {
  const cascade = useYmmCascade()
  cascade.fetchMakes()
  rows.value.push(cascade)
  model.value = [...model.value, { make_id: 0, model_id: null, generation_id: null, note: null }]
}

function removeRow(index: number) {
  rows.value.splice(index, 1)
  const updated = [...model.value]
  updated.splice(index, 1)
  model.value = updated
}

function updateRow(index: number) {
  const row = rows.value[index]
  if (!row) return
  const updated = [...model.value]
  updated[index] = {
    make_id: row.selectedMakeId.value ?? 0,
    model_id: row.selectedModelId.value,
    generation_id: row.selectedGenerationId.value,
    note: null,
  }
  model.value = updated
}

// Init first row if empty
onMounted(() => {
  if (rows.value.length === 0 && model.value.length === 0) {
    // Don't auto-add — user clicks "+ Добавить"
  }
})
</script>

<template>
  <div class="flex flex-col gap-3">
    <div v-for="(row, index) in rows" :key="index">
      <YmmSelect
        v-model:make-id="row.selectedMakeId.value"
        v-model:model-id="row.selectedModelId.value"
        v-model:generation-id="row.selectedGenerationId.value"
        :makes="row.makes.value"
        :models="row.models.value"
        :generations="row.generations.value"
        :loading-makes="row.loadingMakes.value"
        :loading-models="row.loadingModels.value"
        :loading-generations="row.loadingGenerations.value"
        @update:make-id="updateRow(index)"
        @update:model-id="updateRow(index)"
        @update:generation-id="updateRow(index)"
        @remove="removeRow(index)"
      />
    </div>
    <UButton
      variant="link"
      size="sm"
      @click="addRow"
    >
      &#123;&#123; t('listing.addVehicle') }}
    </UButton>
  </div>
</template>

Step 6: Run test + lint

Run: npx vitest run tests/features/ymm-select/ && npm run lint Expected: PASS, no lint errors

Step 7: Commit

bash
git add app/features/ymm-select/ tests/features/ymm-select/
git commit -m "feat(ymm): add YMM cascading select feature (composable + UI)"

Task 5: Geo Cascading Select

Files:

  • Create: app/features/geo-select/composables/useGeoCascade.ts
  • Create: app/features/geo-select/ui/GeoSelect.vue
  • Test: tests/features/geo-select/composables/useGeoCascade.test.ts

Step 1: Write the failing test

ts
// tests/features/geo-select/composables/useGeoCascade.test.ts
import { describe, it, expect } from 'vitest'

describe('Geo cascade', () => {
  it('exports useGeoCascade composable', async () => {
    const mod = await import('~/features/geo-select/composables/useGeoCascade')
    expect(mod.useGeoCascade).toBeDefined()
    expect(typeof mod.useGeoCascade).toBe('function')
  })
})

Step 2: Run test → FAIL

Step 3: Implement composable

ts
// app/features/geo-select/composables/useGeoCascade.ts
import { ref, watch, readonly } from 'vue'

import type { Region, City, District, MetroStation } from '~/entities/geo/model/geo.schema'
import type { ApiListResponse } from '~/shared/api/types'

export interface GeoSelection {
  region_id: number | null
  city_id: number | null
  district_id: number | null
  metro_station_id: number | null
}

export function useGeoCascade() {
  const api = useApi()

  const regions = ref<Region[]>([])
  const cities = ref<City[]>([])
  const districts = ref<District[]>([])
  const metroStations = ref<MetroStation[]>([])

  const selectedRegionId = ref<number | null>(null)
  const selectedCityId = ref<number | null>(null)
  const selectedDistrictId = ref<number | null>(null)
  const selectedMetroStationId = ref<number | null>(null)

  const loadingRegions = ref(false)
  const loadingCities = ref(false)
  const loadingDistricts = ref(false)
  const loadingMetro = ref(false)

  async function fetchRegions() {
    loadingRegions.value = true
    try {
      const response = await api.get<ApiListResponse<Region>>('/store/geo/regions')
      regions.value = response.data
    } finally {
      loadingRegions.value = false
    }
  }

  async function fetchCities(regionId: number) {
    loadingCities.value = true
    cities.value = []
    districts.value = []
    metroStations.value = []
    selectedCityId.value = null
    selectedDistrictId.value = null
    selectedMetroStationId.value = null
    try {
      const response = await api.get<ApiListResponse<City>>(`/store/geo/regions/${regionId}/cities`)
      cities.value = response.data
    } finally {
      loadingCities.value = false
    }
  }

  async function fetchDistricts(cityId: number) {
    loadingDistricts.value = true
    districts.value = []
    selectedDistrictId.value = null
    try {
      const response = await api.get<ApiListResponse<District>>(`/store/geo/cities/${cityId}/districts`)
      districts.value = response.data
    } finally {
      loadingDistricts.value = false
    }
  }

  async function fetchMetro(cityId: number) {
    loadingMetro.value = true
    metroStations.value = []
    selectedMetroStationId.value = null
    try {
      const response = await api.get<ApiListResponse<MetroStation>>(`/store/geo/cities/${cityId}/metro`)
      metroStations.value = response.data
    } finally {
      loadingMetro.value = false
    }
  }

  watch(selectedRegionId, (regionId) => {
    if (regionId) fetchCities(regionId)
    else {
      cities.value = []
      districts.value = []
      metroStations.value = []
      selectedCityId.value = null
      selectedDistrictId.value = null
      selectedMetroStationId.value = null
    }
  })

  watch(selectedCityId, (cityId) => {
    if (cityId) {
      fetchDistricts(cityId)
      fetchMetro(cityId)
    } else {
      districts.value = []
      metroStations.value = []
      selectedDistrictId.value = null
      selectedMetroStationId.value = null
    }
  })

  function getSelection(): GeoSelection {
    return {
      region_id: selectedRegionId.value,
      city_id: selectedCityId.value,
      district_id: selectedDistrictId.value,
      metro_station_id: selectedMetroStationId.value,
    }
  }

  function reset() {
    selectedRegionId.value = null
    selectedCityId.value = null
    selectedDistrictId.value = null
    selectedMetroStationId.value = null
    cities.value = []
    districts.value = []
    metroStations.value = []
  }

  return {
    regions: readonly(regions),
    cities: readonly(cities),
    districts: readonly(districts),
    metroStations: readonly(metroStations),
    selectedRegionId,
    selectedCityId,
    selectedDistrictId,
    selectedMetroStationId,
    loadingRegions: readonly(loadingRegions),
    loadingCities: readonly(loadingCities),
    loadingDistricts: readonly(loadingDistricts),
    loadingMetro: readonly(loadingMetro),
    fetchRegions,
    getSelection,
    reset,
  }
}

Step 4: Implement GeoSelect.vue

html
<!-- app/features/geo-select/ui/GeoSelect.vue -->
<script setup lang="ts">
const geo = useGeoCascade()

const { t } = useI18n()

const regionOptions = computed(() =>
  geo.regions.value.map(r => ({ label: r.name, value: r.id }))
)
const cityOptions = computed(() =>
  geo.cities.value.map(c => ({ label: c.name, value: c.id }))
)
const districtOptions = computed(() =>
  geo.districts.value.map(d => ({ label: d.name, value: d.id }))
)
const metroOptions = computed(() =>
  geo.metroStations.value.map(m => ({ label: `${m.line ? m.line + ' — ' : ''}${m.name}`, value: m.id }))
)

onMounted(() => {
  geo.fetchRegions()
})

defineExpose({
  getSelection: geo.getSelection,
  reset: geo.reset,
  geo,
})
</script>

<template>
  <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
    <UFormField :label="t('geo.region')" name="region_id">
      <USelectMenu
        v-model="geo.selectedRegionId.value"
        :options="regionOptions"
        :placeholder="t('geo.selectRegion')"
        :loading="geo.loadingRegions.value"
        value-attribute="value"
        option-attribute="label"
        searchable
      />
    </UFormField>
    <UFormField :label="t('geo.city')" name="city_id">
      <USelectMenu
        v-model="geo.selectedCityId.value"
        :options="cityOptions"
        :placeholder="t('geo.selectCity')"
        :loading="geo.loadingCities.value"
        :disabled="!geo.selectedRegionId.value"
        value-attribute="value"
        option-attribute="label"
        searchable
      />
    </UFormField>
    <UFormField :label="t('geo.district')" name="district_id">
      <USelectMenu
        v-model="geo.selectedDistrictId.value"
        :options="districtOptions"
        :placeholder="t('geo.selectDistrict')"
        :loading="geo.loadingDistricts.value"
        :disabled="!geo.selectedCityId.value"
        value-attribute="value"
        option-attribute="label"
        searchable
      />
    </UFormField>
    <UFormField :label="t('geo.metro')" name="metro_station_id">
      <USelectMenu
        v-model="geo.selectedMetroStationId.value"
        :options="metroOptions"
        :placeholder="t('geo.selectMetro')"
        :loading="geo.loadingMetro.value"
        :disabled="!geo.selectedCityId.value"
        value-attribute="value"
        option-attribute="label"
        searchable
      />
    </UFormField>
  </div>
</template>

Step 5: Run test + lint

Run: npx vitest run tests/features/geo-select/ && npm run lint

Step 6: Commit

bash
git add app/features/geo-select/ tests/features/geo-select/
git commit -m "feat(geo): add geo cascading select feature (composable + UI)"

Task 6: Image Upload Feature

Files:

  • Create: app/features/image-upload/composables/useImageUpload.ts
  • Create: app/features/image-upload/ui/ImageUploader.vue
  • Test: tests/features/image-upload/composables/useImageUpload.test.ts

Step 1: Write the failing test

ts
// tests/features/image-upload/composables/useImageUpload.test.ts
import { describe, it, expect } from 'vitest'

describe('Image upload', () => {
  it('exports useImageUpload composable', async () => {
    const mod = await import('~/features/image-upload/composables/useImageUpload')
    expect(mod.useImageUpload).toBeDefined()
    expect(typeof mod.useImageUpload).toBe('function')
  })
})

Step 2: Run test → FAIL

Step 3: Implement composable

ts
// app/features/image-upload/composables/useImageUpload.ts
import { ref, readonly } from 'vue'

import type { ProductImage } from '~/entities/product/model/product.schema'
import type { ApiItemResponse } from '~/shared/api/types'

export interface LocalImage {
  id: string // temporary local ID
  file: File
  previewUrl: string
  status: 'pending' | 'uploading' | 'done' | 'error'
  serverImage?: ProductImage
  error?: string
}

export function useImageUpload(productId: Ref<number | null>) {
  const api = useApi()

  const images = ref<ProductImage[]>([])
  const localImages = ref<LocalImage[]>([])
  const uploading = ref(false)

  function setServerImages(serverImages: ProductImage[]) {
    images.value = serverImages
  }

  async function uploadFile(file: File): Promise<ProductImage | null> {
    if (!productId.value) return null

    const formData = new FormData()
    formData.append('image', file)

    try {
      const response = await api.upload<ApiItemResponse<ProductImage>>(
        `/vendor/products/${productId.value}/images`,
        formData,
      )
      return response.data
    } catch (error: unknown) {
      const fetchError = error as { data?: { error?: { message?: string } } }
      throw new Error(fetchError?.data?.error?.message ?? 'Ошибка загрузки')
    }
  }

  async function addFiles(files: FileList | File[]) {
    uploading.value = true

    for (const file of Array.from(files)) {
      const localId = crypto.randomUUID()
      const previewUrl = URL.createObjectURL(file)
      const localImage: LocalImage = {
        id: localId,
        file,
        previewUrl,
        status: 'uploading',
      }
      localImages.value.push(localImage)

      try {
        const serverImage = await uploadFile(file)
        if (serverImage) {
          localImage.status = 'done'
          localImage.serverImage = serverImage
          images.value.push(serverImage)
        }
      } catch (e: unknown) {
        localImage.status = 'error'
        localImage.error = (e as Error).message
      }
    }

    // Clean up done local images (server images are in images ref)
    localImages.value = localImages.value.filter(l => l.status !== 'done')
    uploading.value = false
  }

  async function removeImage(imageId: number) {
    if (!productId.value) return
    await api.delete(`/vendor/products/${productId.value}/images/${imageId}`)
    images.value = images.value.filter(i => i.id !== imageId)
  }

  async function reorderImages(imageIds: number[]) {
    if (!productId.value) return
    await api.put(`/vendor/products/${productId.value}/images/order`, { image_ids: imageIds })
    // Reorder local state to match
    const ordered = imageIds
      .map(id => images.value.find(i => i.id === id))
      .filter((i): i is ProductImage => i !== undefined)
    images.value = ordered
  }

  function removeLocalImage(localId: string) {
    const idx = localImages.value.findIndex(l => l.id === localId)
    if (idx !== -1) {
      URL.revokeObjectURL(localImages.value[idx].previewUrl)
      localImages.value.splice(idx, 1)
    }
  }

  return {
    images: readonly(images),
    localImages: readonly(localImages),
    uploading: readonly(uploading),
    setServerImages,
    addFiles,
    removeImage,
    reorderImages,
    removeLocalImage,
  }
}

Step 4: Implement ImageUploader.vue

html
<!-- app/features/image-upload/ui/ImageUploader.vue -->
<script setup lang="ts">
import type { ProductImage } from '~/entities/product/model/product.schema'

const props = defineProps<{
  images: ProductImage[]
  localImages: { id: string; previewUrl: string; status: string; error?: string }[]
  uploading: boolean
  maxImages?: number
}>()

const emit = defineEmits<{
  addFiles: [files: FileList]
  removeImage: [imageId: number]
  removeLocal: [localId: string]
}>()

const { t } = useI18n()
const fileInput = ref<HTMLInputElement>()
const maxImages = computed(() => props.maxImages ?? 10)
const canAdd = computed(() => props.images.length + props.localImages.length < maxImages.value)

function onFileChange(event: Event) {
  const target = event.target as HTMLInputElement
  if (target.files?.length) {
    emit('addFiles', target.files)
    target.value = ''
  }
}

function openFilePicker() {
  fileInput.value?.click()
}

function getImageUrl(image: ProductImage): string {
  return image.medium_webp ?? image.medium_jpeg ?? image.thumbnail_webp ?? image.thumbnail_jpeg ?? ''
}
</script>

<template>
  <div>
    <input
      ref="fileInput"
      type="file"
      accept="image/jpeg,image/png,image/webp"
      multiple
      class="hidden"
      @change="onFileChange"
    />
    <div class="flex flex-wrap gap-3">
      <!-- Server images -->
      <div
        v-for="image in images"
        :key="image.id"
        class="relative w-[100px] h-[100px] rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 group"
        :class="{ 'w-[140px] h-[140px]': image.is_primary }"
      >
        <img
          :src="getImageUrl(image)"
          :alt="image.is_primary ? t('listing.mainPhoto') : ''"
          class="w-full h-full object-cover"
        />
        <div
          v-if="image.status === 'processing'"
          class="absolute inset-0 bg-black/50 flex items-center justify-center"
        >
          <UIcon name="i-heroicons-arrow-path" class="animate-spin text-white" />
        </div>
        <button
          class="absolute top-1 right-1 bg-black/60 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
          @click="emit('removeImage', image.id)"
        >
          <UIcon name="i-heroicons-x-mark" class="text-white w-4 h-4" />
        </button>
        <span
          v-if="image.is_primary"
          class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs text-center py-0.5"
        >
          &#123;&#123; t('listing.mainPhoto') }}
        </span>
      </div>

      <!-- Local images (uploading/error) -->
      <div
        v-for="local in localImages"
        :key="local.id"
        class="relative w-[100px] h-[100px] rounded-lg overflow-hidden border border-dashed"
        :class="local.status === 'error' ? 'border-red-400' : 'border-gray-300'"
      >
        <img :src="local.previewUrl" class="w-full h-full object-cover opacity-60" />
        <div class="absolute inset-0 flex items-center justify-center">
          <UIcon
            v-if="local.status === 'uploading'"
            name="i-heroicons-arrow-path"
            class="animate-spin text-primary"
          />
          <UIcon
            v-else-if="local.status === 'error'"
            name="i-heroicons-exclamation-triangle"
            class="text-red-500"
          />
        </div>
        <button
          v-if="local.status === 'error'"
          class="absolute top-1 right-1 bg-black/60 rounded-full p-0.5"
          @click="emit('removeLocal', local.id)"
        >
          <UIcon name="i-heroicons-x-mark" class="text-white w-4 h-4" />
        </button>
      </div>

      <!-- Add button -->
      <button
        v-if="canAdd"
        class="w-[100px] h-[100px] rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center text-gray-400 hover:border-primary hover:text-primary transition-colors cursor-pointer"
        @click="openFilePicker"
      >
        <UIcon name="i-heroicons-plus" class="w-8 h-8" />
      </button>
    </div>
  </div>
</template>

Step 5: Run test + lint

Run: npx vitest run tests/features/image-upload/ && npm run lint

Step 6: Commit

bash
git add app/features/image-upload/ tests/features/image-upload/
git commit -m "feat(images): add image upload feature (composable + uploader UI)"

Task 7: Product Status Badge

Files:

  • Create: app/entities/product/ui/ProductStatusBadge.vue

Step 1: Implement

html
<!-- app/entities/product/ui/ProductStatusBadge.vue -->
<script setup lang="ts">
import type { ProductStatus } from '~/entities/product/model/product.schema'

const props = defineProps<{
  status: ProductStatus
}>()

const { t } = useI18n()

const config = computed(() => {
  const map: Record<ProductStatus, { label: string; color: string }> = {
    draft: { label: t('myListings.statusDraft'), color: 'neutral' },
    pending: { label: t('myListings.statusPending'), color: 'warning' },
    active: { label: t('myListings.statusActive'), color: 'success' },
    sold: { label: t('myListings.statusSold'), color: 'info' },
    archived: { label: t('myListings.statusArchived'), color: 'neutral' },
    rejected: { label: t('myListings.statusRejected'), color: 'error' },
  }
  return map[props.status]
})
</script>

<template>
  <UBadge :color="config.color" variant="subtle" size="sm">
    &#123;&#123; config.label }}
  </UBadge>
</template>

Step 2: Commit

bash
git add app/entities/product/ui/ProductStatusBadge.vue
git commit -m "feat(product): add ProductStatusBadge component"

Task 8: Product Card Component

Files:

  • Create: app/entities/product/ui/ProductCard.vue

Step 1: Implement

html
<!-- app/entities/product/ui/ProductCard.vue -->
<script setup lang="ts">
import type { Product, ProductImage } from '~/entities/product/model/product.schema'

const props = defineProps<{
  product: Product & {
    images?: ProductImage[]
    city_id?: number
    primary_category_id?: number | null
  }
  showStatus?: boolean
}>()

const { t } = useI18n()

const imageUrl = computed(() => {
  const primary = props.product.images?.find(i => i.is_primary) ?? props.product.images?.[0]
  return primary?.medium_webp ?? primary?.medium_jpeg ?? primary?.thumbnail_webp ?? null
})

const formattedPrice = computed(() => {
  return new Intl.NumberFormat('ru-RU').format(props.product.price) + ' ₽'
})
</script>

<template>
  <NuxtLink
    :to="`/product/${product.id}`"
    class="block rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow"
  >
    <div class="aspect-[4/3] bg-gray-100 dark:bg-gray-800 relative">
      <img
        v-if="imageUrl"
        :src="imageUrl"
        :alt="product.title"
        class="w-full h-full object-cover"
        loading="lazy"
      />
      <div v-else class="w-full h-full flex items-center justify-center text-gray-400">
        <UIcon name="i-heroicons-photo" class="w-12 h-12" />
      </div>
      <ProductStatusBadge
        v-if="showStatus"
        :status="product.status"
        class="absolute top-2 left-2"
      />
    </div>
    <div class="p-3">
      <h3 class="font-medium text-sm line-clamp-2">&#123;&#123; product.title }}</h3>
      <p class="text-lg font-bold mt-1">&#123;&#123; formattedPrice }}</p>
    </div>
  </NuxtLink>
</template>

Step 2: Commit

bash
git add app/entities/product/ui/ProductCard.vue
git commit -m "feat(product): add ProductCard component"

Task 9: Product Form Feature

Files:

  • Create: app/features/product-form/composables/useProductForm.ts
  • Create: app/features/product-form/ui/ProductFormPage.vue

This is the largest task. The form composable manages state, draft auto-save, and submit logic. The UI component is the full form.

Step 1: Implement composable

ts
// app/features/product-form/composables/useProductForm.ts
import { ref, reactive, readonly } from 'vue'

import type { ProductDetail, ProductForm, Compatibility, ProductCategory } from '~/entities/product/model/product.schema'
import type { Category } from '~/entities/category/model/category.schema'
import type { ApiItemResponse, ApiListResponse } from '~/shared/api/types'

type FormMode = 'create' | 'edit'

export function useProductForm(mode: FormMode, existingId?: number) {
  const api = useApi()
  const { t } = useI18n()

  const productId = ref<number | null>(existingId ?? null)
  const saving = ref(false)
  const publishing = ref(false)
  const error = ref<string | null>(null)
  const fieldErrors = ref<Record<string, string[]>>({})
  const draftSaved = ref(false)

  // Form state
  const form = reactive<ProductForm>({
    title: '',
    description: '',
    price: 0,
    steering: 'universal',
    oem_number: '',
    manufacturer: '',
    city_id: 0,
    district_id: undefined,
    metro_station_id: undefined,
    address: '',
    category_ids: [],
    condition_category_id: 0,
    compatibility: [],
  })

  // Categories loaded from API
  const partCategories = ref<Category[]>([])
  const conditionCategories = ref<Category[]>([])

  async function loadCategories() {
    const response = await api.get<ApiListResponse<Category>>('/store/categories')
    partCategories.value = response.data.filter(c => c.category_type === 'part')
    conditionCategories.value = response.data.filter(c => c.category_type === 'condition')
  }

  async function loadProduct() {
    if (!productId.value) return
    const response = await api.get<ApiItemResponse<ProductDetail>>(`/vendor/products/${productId.value}`)
    const p = response.data
    form.title = p.title
    form.description = p.description ?? ''
    form.price = p.price
    form.steering = p.steering
    form.oem_number = p.oem_number ?? ''
    form.manufacturer = p.manufacturer ?? ''
    form.city_id = p.city_id
    form.district_id = p.district_id ?? undefined
    form.metro_station_id = p.metro_station_id ?? undefined
    form.address = p.address ?? ''
    form.category_ids = p.categories.filter(c => {
      const cat = partCategories.value.find(pc => pc.id === c.category_id)
      return cat !== undefined
    })
    const conditionCat = p.categories.find(c => {
      return conditionCategories.value.some(cc => cc.id === c.category_id)
    })
    form.condition_category_id = conditionCat?.category_id ?? 0
    form.compatibility = p.compatibility
    return p
  }

  function buildRequestBody() {
    const allCategories = [
      ...form.category_ids,
      ...(form.condition_category_id ? [{ category_id: form.condition_category_id, is_primary: false }] : []),
    ]
    return {
      title: form.title,
      description: form.description || undefined,
      price: form.price,
      steering: form.steering,
      oem_number: form.oem_number || undefined,
      manufacturer: form.manufacturer || undefined,
      city_id: form.city_id,
      district_id: form.district_id || undefined,
      metro_station_id: form.metro_station_id || undefined,
      address: form.address || undefined,
      category_ids: allCategories,
      compatibility: form.compatibility.filter(c => c.make_id > 0),
    }
  }

  async function saveDraft(): Promise<number | null> {
    saving.value = true
    error.value = null
    fieldErrors.value = {}

    try {
      const body = buildRequestBody()

      if (productId.value) {
        await api.put(`/vendor/products/${productId.value}`, body)
      } else {
        const response = await api.post<ApiItemResponse<ProductDetail>>('/vendor/products', body)
        productId.value = response.data.id
      }
      draftSaved.value = true
      return productId.value
    } catch (e: unknown) {
      handleApiError(e)
      return null
    } finally {
      saving.value = false
    }
  }

  async function publish(): Promise<boolean> {
    // Save first if not saved
    if (!productId.value) {
      const id = await saveDraft()
      if (!id) return false
    }

    publishing.value = true
    error.value = null

    try {
      await api.post(`/vendor/products/${productId.value}/publish`)
      return true
    } catch (e: unknown) {
      handleApiError(e)
      return false
    } finally {
      publishing.value = false
    }
  }

  async function ensureDraftForUpload(): Promise<number | null> {
    if (productId.value) return productId.value
    // Auto-create draft with minimal data
    return saveDraft()
  }

  function handleApiError(e: unknown) {
    const fetchError = e as { data?: { error?: { code?: string; message?: string; details?: Record<string, string[]> } } }
    const apiError = fetchError?.data?.error
    if (apiError) {
      error.value = apiError.message ?? 'Произошла ошибка'
      if (apiError.details) {
        fieldErrors.value = apiError.details
      }
    } else {
      error.value = 'Произошла ошибка'
    }
  }

  return {
    productId: readonly(productId),
    productIdRef: productId, // mutable ref for image upload binding
    form,
    saving: readonly(saving),
    publishing: readonly(publishing),
    error: readonly(error),
    fieldErrors: readonly(fieldErrors),
    draftSaved: readonly(draftSaved),
    partCategories: readonly(partCategories),
    conditionCategories: readonly(conditionCategories),
    loadCategories,
    loadProduct,
    saveDraft,
    publish,
    ensureDraftForUpload,
  }
}

Step 2: Implement ProductFormPage.vue

This is large — it's the full form page. Key sections: category, title, photos, YMM compatibility, specs (OEM, condition, manufacturer, steering), description, price, location, action buttons.

html
<!-- app/features/product-form/ui/ProductFormPage.vue -->
<script setup lang="ts">
import { productFormSchema } from '~/entities/product/model/product.schema'

const props = defineProps<{
  mode: 'create' | 'edit'
  productId?: number
}>()

const { t } = useI18n()
const router = useRouter()

const {
  productId,
  productIdRef,
  form,
  saving,
  publishing,
  error,
  draftSaved,
  partCategories,
  conditionCategories,
  loadCategories,
  loadProduct,
  saveDraft,
  publish,
  ensureDraftForUpload,
} = useProductForm(props.mode, props.productId)

// Image upload
const imageUpload = useImageUpload(productIdRef)

// Category helpers
const parentCategories = computed(() =>
  partCategories.value.filter(c => c.parent_id === null)
)

function childCategories(parentId: number) {
  return partCategories.value.filter(c => c.parent_id === parentId)
}

const selectedParentCategoryId = ref<number | null>(null)
const selectedChildCategoryId = ref<number | null>(null)

watch(selectedChildCategoryId, (catId) => {
  if (catId) {
    form.category_ids = [{ category_id: catId, is_primary: true }]
  }
})

// Photo handling with auto-draft
async function onAddFiles(files: FileList) {
  const id = await ensureDraftForUpload()
  if (id) {
    imageUpload.addFiles(files)
  }
}

// Submit handlers
async function onSaveDraft() {
  await saveDraft()
}

async function onPublish() {
  const success = await publish()
  if (success) {
    await router.push('/cabinet')
  }
}

// Load data
onMounted(async () => {
  await loadCategories()
  if (props.mode === 'edit' && props.productId) {
    const product = await loadProduct()
    if (product) {
      imageUpload.setServerImages(product.images)
      // Restore category selection
      const primaryCat = product.categories.find(c => c.is_primary)
      if (primaryCat) {
        const cat = partCategories.value.find(pc => pc.id === primaryCat.category_id)
        if (cat) {
          selectedParentCategoryId.value = cat.parent_id
          selectedChildCategoryId.value = cat.id
        }
      }
    }
  }
})
</script>

<template>
  <div class="max-w-2xl">
    <h1 class="text-2xl font-bold mb-6">
      &#123;&#123; mode === 'create' ? t('listing.newListing') : t('listing.editListing') }}
    </h1>

    <UAlert v-if="error" color="error" :description="error" class="mb-4" />
    <UAlert v-if="draftSaved" color="success" :description="t('listing.draftSaved')" class="mb-4" />

    <div class="flex flex-col gap-6">
      <!-- Category -->
      <div>
        <h3 class="font-semibold mb-2">&#123;&#123; t('listing.category') }}</h3>
        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
          <USelectMenu
            v-model="selectedParentCategoryId"
            :options="parentCategories.map(c => ({ label: c.name, value: c.id }))"
            :placeholder="t('listing.selectCategory')"
            value-attribute="value"
            option-attribute="label"
            searchable
          />
          <USelectMenu
            v-if="selectedParentCategoryId"
            v-model="selectedChildCategoryId"
            :options="childCategories(selectedParentCategoryId).map(c => ({ label: c.name, value: c.id }))"
            :placeholder="t('listing.selectSubcategory')"
            value-attribute="value"
            option-attribute="label"
            searchable
          />
        </div>
      </div>

      <!-- Title -->
      <UFormField :label="t('listing.title')" name="title">
        <UInput v-model="form.title" :placeholder="t('listing.titlePlaceholder')" />
      </UFormField>

      <!-- Photos -->
      <div>
        <h3 class="font-semibold mb-2">&#123;&#123; t('listing.photos') }}</h3>
        <ImageUploader
          :images="imageUpload.images.value"
          :local-images="imageUpload.localImages.value"
          :uploading="imageUpload.uploading.value"
          @add-files="onAddFiles"
          @remove-image="imageUpload.removeImage"
          @remove-local="imageUpload.removeLocalImage"
        />
      </div>

      <!-- Vehicle Compatibility -->
      <div>
        <h3 class="font-semibold mb-2">&#123;&#123; t('listing.vehicleCompat') }}</h3>
        <YmmMultiSelect v-model="form.compatibility" />
      </div>

      <!-- Specs -->
      <div>
        <h3 class="font-semibold mb-2">&#123;&#123; t('listing.specs') }}</h3>
        <div class="flex flex-col gap-3">
          <UFormField :label="t('listing.oemNumber')" name="oem_number">
            <UInput v-model="form.oem_number" :placeholder="t('listing.oemPlaceholder')" />
          </UFormField>

          <UFormField :label="t('listing.conditionLabel')" name="condition_category_id">
            <div class="flex gap-3">
              <label
                v-for="cond in conditionCategories"
                :key="cond.id"
                class="flex items-center gap-2 cursor-pointer"
              >
                <input
                  type="radio"
                  :value="cond.id"
                  :checked="form.condition_category_id === cond.id"
                  class="accent-primary"
                  @change="form.condition_category_id = cond.id"
                />
                &#123;&#123; cond.name }}
              </label>
            </div>
          </UFormField>

          <UFormField :label="t('listing.manufacturer')" name="manufacturer">
            <UInput v-model="form.manufacturer" :placeholder="t('listing.manufacturerPlaceholder')" />
          </UFormField>

          <UFormField :label="t('listing.steering')" name="steering">
            <div class="flex gap-3">
              <label
                v-for="opt in (['universal', 'left', 'right'] as const)"
                :key="opt"
                class="flex items-center gap-2 cursor-pointer"
              >
                <input
                  type="radio"
                  :value="opt"
                  :checked="form.steering === opt"
                  class="accent-primary"
                  @change="form.steering = opt"
                />
                &#123;&#123; t(`listing.steering${opt.charAt(0).toUpperCase() + opt.slice(1)}`) }}
              </label>
            </div>
          </UFormField>
        </div>
      </div>

      <!-- Description -->
      <UFormField :label="t('listing.description')" name="description">
        <UTextarea
          v-model="form.description"
          :placeholder="t('listing.descriptionPlaceholder')"
          :rows="4"
        />
      </UFormField>

      <!-- Price -->
      <UFormField :label="t('product.price')" name="price">
        <UInput v-model.number="form.price" type="number" :placeholder="t('listing.pricePlaceholder')">
          <template #trailing>
            <span class="text-gray-500">&#123;&#123; t('listing.currency') }}</span>
          </template>
        </UInput>
      </UFormField>

      <!-- Location -->
      <div>
        <h3 class="font-semibold mb-2">&#123;&#123; t('listing.location') }}</h3>
        <GeoSelect ref="geoSelectRef" />
        <UFormField :label="t('listing.address')" name="address" class="mt-3">
          <UInput v-model="form.address" :placeholder="t('listing.addressPlaceholder')" />
        </UFormField>
      </div>

      <!-- Actions -->
      <div class="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
        <UButton
          color="primary"
          size="lg"
          :loading="publishing"
          @click="onPublish"
        >
          &#123;&#123; t('listing.publish') }}
        </UButton>
        <UButton
          variant="outline"
          size="lg"
          :loading="saving"
          @click="onSaveDraft"
        >
          &#123;&#123; t('listing.saveDraft') }}
        </UButton>
      </div>
    </div>
  </div>
</template>

Step 3: Run lint

Run: npm run lint

Step 4: Commit

bash
git add app/features/product-form/
git commit -m "feat(product-form): add product form feature (composable + full form UI)"

Task 10: Cabinet — My Listings Page

Files:

  • Modify: app/pages/cabinet/index.vue

Step 1: Implement

html
<!-- app/pages/cabinet/index.vue -->
<script setup lang="ts">
import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { ApiListResponse } from '~/shared/api/types'

definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Мои объявления', robots: 'noindex, nofollow' })

const { t } = useI18n()
const api = useApi()

const statusFilter = ref<string | null>(null)

const statusTabs = [
  { label: t('myListings.all'), value: null },
  { label: t('myListings.active'), value: 'active' },
  { label: t('myListings.draft'), value: 'draft' },
  { label: t('myListings.pending'), value: 'pending' },
  { label: t('myListings.sold'), value: 'sold' },
  { label: t('myListings.rejected'), value: 'rejected' },
  { label: t('myListings.archived'), value: 'archived' },
]

type ProductWithImages = Product & { images?: ProductImage[] }

const { items, hasMore, isLoading, refresh, loadMore } = useCursorPagination<ProductWithImages>(
  (params) => {
    const query: Record<string, unknown> = { ...params }
    if (statusFilter.value) query.status = statusFilter.value
    return api.get<ApiListResponse<ProductWithImages>>('/vendor/products', query)
  },
  {},
  { limit: 20 },
)

watch(statusFilter, () => {
  refresh()
})

onMounted(() => {
  refresh()
})

async function deleteProduct(id: number) {
  await api.delete(`/vendor/products/${id}`)
  refresh()
}
</script>

<template>
  <div>
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold">&#123;&#123; t('myListings.title') }}</h1>
      <UButton color="primary" to="/cabinet/products/new">
        &#123;&#123; t('common.addListing') }}
      </UButton>
    </div>

    <!-- Status tabs -->
    <div class="flex gap-2 mb-6 flex-wrap">
      <UButton
        v-for="tab in statusTabs"
        :key="tab.value ?? 'all'"
        :variant="statusFilter === tab.value ? 'solid' : 'ghost'"
        size="sm"
        @click="statusFilter = tab.value"
      >
        &#123;&#123; tab.label }}
      </UButton>
    </div>

    <!-- Empty state -->
    <div v-if="!isLoading && items.length === 0" class="text-center py-12">
      <p class="text-gray-500 mb-4">&#123;&#123; t('myListings.empty') }}</p>
      <UButton color="primary" to="/cabinet/products/new">
        &#123;&#123; t('myListings.createFirst') }}
      </UButton>
    </div>

    <!-- Product grid -->
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
      <div v-for="product in items" :key="product.id" class="relative">
        <ProductCard :product="product" show-status />
        <div class="flex gap-2 mt-2">
          <UButton
            v-if="product.status === 'draft' || product.status === 'rejected'"
            size="xs"
            variant="outline"
            :to="`/cabinet/products/${product.id}/edit`"
          >
            &#123;&#123; t('common.edit') }}
          </UButton>
          <UButton
            v-if="product.status === 'draft'"
            size="xs"
            variant="outline"
            color="error"
            @click="deleteProduct(product.id)"
          >
            &#123;&#123; t('common.delete') }}
          </UButton>
        </div>
      </div>
    </div>

    <!-- Load more -->
    <div v-if="hasMore" class="text-center mt-6">
      <UButton variant="outline" :loading="isLoading" @click="loadMore">
        &#123;&#123; t('common.loadMore') }}
      </UButton>
    </div>
  </div>
</template>

Step 2: Commit

bash
git add app/pages/cabinet/index.vue
git commit -m "feat(cabinet): implement My Listings page with status filters"

Task 11: Cabinet — Create & Edit Pages

Files:

  • Modify: app/pages/cabinet/products/new.vue
  • Modify: app/pages/cabinet/products/[id]/edit.vue

Step 1: Implement create page

html
<!-- app/pages/cabinet/products/new.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Новое объявление', robots: 'noindex, nofollow' })
</script>

<template>
  <ProductFormPage mode="create" />
</template>

Step 2: Implement edit page

html
<!-- app/pages/cabinet/products/[id]/edit.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'cabinet', middleware: 'auth' })
useSeoMeta({ title: 'Редактирование объявления', robots: 'noindex, nofollow' })

const route = useRoute()
const productId = Number(route.params.id)
</script>

<template>
  <ProductFormPage mode="edit" :product-id="productId" />
</template>

Step 3: Commit

bash
git add app/pages/cabinet/products/new.vue app/pages/cabinet/products/\[id\]/edit.vue
git commit -m "feat(cabinet): implement create and edit listing pages"

Task 12: Product Detail Page (Public)

Files:

  • Modify: app/pages/product/[id].vue

Step 1: Implement

html
<!-- app/pages/product/[id].vue -->
<script setup lang="ts">
import type { ProductDetail } from '~/entities/product/model/product.schema'
import type { ApiItemResponse } from '~/shared/api/types'

definePageMeta({ layout: 'default' })

const route = useRoute()
const productId = Number(route.params.id)
const { t } = useI18n()
const api = useApi()

const { data: product, error } = await useAsyncData(
  `product-${productId}`,
  () => api.get<ApiItemResponse<ProductDetail>>(`/store/products/${productId}`).then(r => r.data),
)

if (product.value) {
  const primaryImage = product.value.images.find(i => i.is_primary) ?? product.value.images[0]
  useSeoMeta({
    title: product.value.title,
    description: product.value.description?.substring(0, 160) ?? product.value.title,
    ogImage: primaryImage?.large_webp ?? primaryImage?.large_jpeg,
  })
}

const selectedImageIndex = ref(0)

const formattedPrice = computed(() => {
  if (!product.value) return ''
  return new Intl.NumberFormat('ru-RU').format(product.value.price) + ' ₽'
})

const memberSinceDate = computed(() => {
  if (!product.value?.seller.created_at) return ''
  return new Date(product.value.seller.created_at).toLocaleDateString('ru-RU', {
    month: 'long',
    year: 'numeric',
  })
})
</script>

<template>
  <div v-if="error" class="container mx-auto px-4 py-12 text-center">
    <h1 class="text-2xl font-bold mb-2">&#123;&#123; t('productDetail.notFound') }}</h1>
    <p class="text-gray-500 mb-4">&#123;&#123; t('productDetail.notFoundDescription') }}</p>
    <UButton to="/catalog">&#123;&#123; t('catalog.filters') }}</UButton>
  </div>

  <div v-else-if="product" class="container mx-auto px-4 py-6">
    <div class="flex flex-col lg:flex-row gap-8">
      <!-- Gallery -->
      <div class="lg:w-1/2">
        <div class="aspect-[4/3] bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
          <img
            v-if="product.images[selectedImageIndex]"
            :src="product.images[selectedImageIndex]?.large_webp ?? product.images[selectedImageIndex]?.large_jpeg ?? ''"
            :alt="product.title"
            class="w-full h-full object-contain"
          />
          <div v-else class="w-full h-full flex items-center justify-center text-gray-400">
            <UIcon name="i-heroicons-photo" class="w-16 h-16" />
          </div>
        </div>
        <div v-if="product.images.length > 1" class="flex gap-2 mt-3 overflow-x-auto">
          <button
            v-for="(image, index) in product.images"
            :key="image.id"
            class="w-[72px] h-[72px] rounded-lg overflow-hidden border-2 shrink-0"
            :class="selectedImageIndex === index ? 'border-primary' : 'border-transparent'"
            @click="selectedImageIndex = index"
          >
            <img
              :src="image.thumbnail_webp ?? image.thumbnail_jpeg ?? ''"
              class="w-full h-full object-cover"
            />
          </button>
        </div>
      </div>

      <!-- Info -->
      <div class="lg:w-1/2">
        <h1 class="text-2xl font-bold">&#123;&#123; product.title }}</h1>
        <p class="text-3xl font-bold mt-2">&#123;&#123; formattedPrice }}</p>

        <div class="flex gap-3 mt-4">
          <UButton color="primary" size="lg" disabled>
            &#123;&#123; t('productDetail.contactSeller') }}
          </UButton>
          <UButton variant="outline" size="lg" disabled>
            &#123;&#123; t('product.showPhone') }}
          </UButton>
        </div>

        <!-- Specs -->
        <div class="mt-6">
          <h3 class="font-semibold mb-2">&#123;&#123; t('productDetail.characteristics') }}</h3>
          <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 flex flex-col gap-2">
            <div v-if="product.oem_number" class="flex justify-between">
              <span class="text-gray-500">OEM</span>
              <span class="font-medium">&#123;&#123; product.oem_number }}</span>
            </div>
            <div v-if="product.manufacturer" class="flex justify-between">
              <span class="text-gray-500">&#123;&#123; t('listing.manufacturer') }}</span>
              <span class="font-medium">&#123;&#123; product.manufacturer }}</span>
            </div>
            <div class="flex justify-between">
              <span class="text-gray-500">&#123;&#123; t('listing.steering') }}</span>
              <span class="font-medium">&#123;&#123; t(`listing.steering${product.steering.charAt(0).toUpperCase() + product.steering.slice(1)}`) }}</span>
            </div>
          </div>
        </div>

        <!-- Compatibility -->
        <div v-if="product.compatibility.length" class="mt-6">
          <h3 class="font-semibold mb-2">&#123;&#123; t('productDetail.fitsFor') }}</h3>
          <div class="flex flex-col gap-1">
            <div
              v-for="(compat, i) in product.compatibility"
              :key="i"
              class="text-sm text-gray-600 dark:text-gray-400"
            >
              &#123;&#123; compat.note ?? `Make ${compat.make_id}, Model ${compat.model_id ?? '—'}` }}
            </div>
          </div>
        </div>

        <!-- Description -->
        <div v-if="product.description" class="mt-6">
          <h3 class="font-semibold mb-2">&#123;&#123; t('productDetail.description') }}</h3>
          <p class="text-gray-600 dark:text-gray-400 whitespace-pre-line">&#123;&#123; product.description }}</p>
        </div>

        <!-- Seller -->
        <div class="mt-6 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
          <NuxtLink :to="`/seller/${product.seller.id}`" class="flex items-center gap-3">
            <div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
              <img v-if="product.seller.avatar_url" :src="product.seller.avatar_url" class="w-full h-full rounded-full object-cover" />
              <UIcon v-else name="i-heroicons-user" class="w-6 h-6 text-gray-400" />
            </div>
            <div>
              <div class="font-medium">&#123;&#123; product.seller.display_name }}</div>
              <div class="text-sm text-gray-500">
                &#123;&#123; t('productDetail.memberSince', { date: memberSinceDate }) }}
              </div>
            </div>
          </NuxtLink>
        </div>
      </div>
    </div>
  </div>
</template>

Step 2: Commit

bash
git add app/pages/product/\\[id\\].vue
git commit -m "feat(product): implement product detail page with gallery, specs, seller"

Task 13: Catalog Page (Public)

Files:

  • Modify: app/pages/catalog/index.vue

Step 1: Implement

html
<!-- app/pages/catalog/index.vue -->
<script setup lang="ts">
import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { Category } from '~/entities/category/model/category.schema'
import type { ApiListResponse } from '~/shared/api/types'

definePageMeta({ layout: 'default' })
useSeoMeta({
  title: 'Каталог автозапчастей',
  description: 'Автозапчасти в Санкт-Петербурге — каталог объявлений на Partizap',
})

const { t } = useI18n()
const api = useApi()
const route = useRoute()
const router = useRouter()

// Filters from URL
const filters = reactive({
  category_id: Number(route.query.category_id) || undefined,
  make_id: Number(route.query.make_id) || undefined,
  model_id: Number(route.query.model_id) || undefined,
  generation_id: Number(route.query.generation_id) || undefined,
  price_min: Number(route.query.price_min) || undefined,
  price_max: Number(route.query.price_max) || undefined,
  steering: (route.query.steering as string) || undefined,
  region_id: Number(route.query.region_id) || undefined,
  city_id: Number(route.query.city_id) || undefined,
  sort: (route.query.sort as string) || 'date_desc',
})

// Categories
const categories = ref<Category[]>([])
onMounted(async () => {
  const res = await api.get<ApiListResponse<Category>>('/store/categories')
  categories.value = res.data.filter(c => c.category_type === 'part')
})

const parentCategories = computed(() =>
  categories.value.filter(c => c.parent_id === null)
)

// YMM cascade for filters
const ymm = useYmmCascade()
onMounted(() => ymm.fetchMakes())

type ProductWithImages = Product & { images?: ProductImage[]; city_id?: number }

const { items, hasMore, isLoading, refresh, loadMore } = useCursorPagination<ProductWithImages>(
  (params) => {
    const query: Record<string, unknown> = { ...params }
    for (const [key, val] of Object.entries(filters)) {
      if (val !== undefined && val !== 0) query[key] = val
    }
    return api.get<ApiListResponse<ProductWithImages>>('/store/products', query)
  },
  {},
  { limit: 20 },
)

// Update URL when filters change
function applyFilters() {
  const query: Record<string, string> = {}
  for (const [key, val] of Object.entries(filters)) {
    if (val !== undefined && val !== 0 && val !== 'date_desc') {
      query[key] = String(val)
    }
  }
  router.replace({ query })
  refresh()
}

const sortOptions = [
  { label: t('catalog.sortByDate'), value: 'date_desc' },
  { label: t('catalog.sortByPriceAsc'), value: 'price_asc' },
  { label: t('catalog.sortByPriceDesc'), value: 'price_desc' },
]

function resetFilters() {
  Object.assign(filters, {
    category_id: undefined,
    make_id: undefined,
    model_id: undefined,
    generation_id: undefined,
    price_min: undefined,
    price_max: undefined,
    steering: undefined,
    region_id: undefined,
    city_id: undefined,
    sort: 'date_desc',
  })
  ymm.reset()
  applyFilters()
}

onMounted(() => refresh())
</script>

<template>
  <div class="container mx-auto px-4 py-6">
    <h1 class="text-2xl font-bold mb-6">Каталог автозапчастей</h1>

    <div class="flex flex-col lg:flex-row gap-6">
      <!-- Sidebar filters (desktop) -->
      <aside class="lg:w-64 shrink-0">
        <div class="flex flex-col gap-4">
          <!-- Category -->
          <UFormField :label="t('listing.category')">
            <USelectMenu
              v-model="filters.category_id"
              :options="parentCategories.map(c => ({ label: c.name, value: c.id }))"
              :placeholder="t('listing.selectCategory')"
              value-attribute="value"
              option-attribute="label"
              searchable
              @update:model-value="applyFilters"
            />
          </UFormField>

          <!-- YMM -->
          <UFormField :label="t('ymm.make')">
            <USelectMenu
              v-model="ymm.selectedMakeId.value"
              :options="ymm.makes.value.map(m => ({ label: m.name, value: m.id }))"
              :placeholder="t('ymm.selectMake')"
              :loading="ymm.loadingMakes.value"
              value-attribute="value"
              option-attribute="label"
              searchable
              @update:model-value="(v) => { filters.make_id = v; filters.model_id = undefined; filters.generation_id = undefined; applyFilters() }"
            />
          </UFormField>
          <UFormField v-if="ymm.selectedMakeId.value" :label="t('ymm.model')">
            <USelectMenu
              v-model="ymm.selectedModelId.value"
              :options="ymm.models.value.map(m => ({ label: m.name, value: m.id }))"
              :placeholder="t('ymm.selectModel')"
              :loading="ymm.loadingModels.value"
              value-attribute="value"
              option-attribute="label"
              searchable
              @update:model-value="(v) => { filters.model_id = v; filters.generation_id = undefined; applyFilters() }"
            />
          </UFormField>

          <!-- Price range -->
          <div class="grid grid-cols-2 gap-2">
            <UFormField label="Цена от">
              <UInput v-model.number="filters.price_min" type="number" placeholder="0" @blur="applyFilters" />
            </UFormField>
            <UFormField label="Цена до">
              <UInput v-model.number="filters.price_max" type="number" placeholder="∞" @blur="applyFilters" />
            </UFormField>
          </div>

          <!-- Sort -->
          <UFormField :label="t('catalog.sort')">
            <USelectMenu
              v-model="filters.sort"
              :options="sortOptions"
              value-attribute="value"
              option-attribute="label"
              @update:model-value="applyFilters"
            />
          </UFormField>

          <UButton variant="link" size="sm" @click="resetFilters">
            &#123;&#123; t('catalog.resetFilters') }}
          </UButton>
        </div>
      </aside>

      <!-- Product grid -->
      <div class="flex-1">
        <div v-if="!isLoading && items.length === 0" class="text-center py-12">
          <p class="text-gray-500">&#123;&#123; t('catalog.noResults') }}</p>
        </div>

        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
          <ProductCard v-for="product in items" :key="product.id" :product="product" />
        </div>

        <div v-if="hasMore" class="text-center mt-6">
          <UButton variant="outline" :loading="isLoading" @click="loadMore">
            &#123;&#123; t('common.loadMore') }}
          </UButton>
        </div>

        <div v-if="isLoading && items.length === 0" class="text-center py-12">
          <UIcon name="i-heroicons-arrow-path" class="animate-spin w-8 h-8 text-gray-400" />
        </div>
      </div>
    </div>
  </div>
</template>

Step 2: Commit

bash
git add app/pages/catalog/index.vue
git commit -m "feat(catalog): implement catalog page with filters, YMM, pagination"

Task 14: Integration Testing & Polish

Step 1: Run full test suite

Run: npx vitest run Expected: All tests pass

Step 2: Run linter

Run: npm run lint:fix Expected: No errors

Step 3: Run typecheck

Run: npm run typecheck Expected: No type errors

Step 4: Manual verification — test POST /vendor/products on dev

Run dev server and test creating a product via the UI. This will also check if phone_verified_at blocks product creation.

Run: npm run dev

Navigate to http://localhost:3000/cabinet/products/new, fill the form, save draft. Report result.

Step 5: Commit any fixes

bash
git add -A
git commit -m "fix: polish listing pages after integration testing"

Task 15: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Update the Progress section to mark listings as done:

markdown
- [x] Listings CRUD (create, edit, list, publish/draft flow)
- [x] Image upload (multipart, reorder, delete)
- [x] YMM cascading select
- [x] Geo cascading select
- [x] Product detail page (SSR, SEO)
- [x] Catalog with filters and pagination

Add new features/composables to the Project Structure section.

Step 1: Update and commit

bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with listings feature progress"