Appearance
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"
>
{{ 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"
>
{{ 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">
{{ 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">{{ product.title }}</h3>
<p class="text-lg font-bold mt-1">{{ 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">
{{ 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">{{ 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">{{ 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">{{ t('listing.vehicleCompat') }}</h3>
<YmmMultiSelect v-model="form.compatibility" />
</div>
<!-- Specs -->
<div>
<h3 class="font-semibold mb-2">{{ 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"
/>
{{ 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"
/>
{{ 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">{{ t('listing.currency') }}</span>
</template>
</UInput>
</UFormField>
<!-- Location -->
<div>
<h3 class="font-semibold mb-2">{{ 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"
>
{{ t('listing.publish') }}
</UButton>
<UButton
variant="outline"
size="lg"
:loading="saving"
@click="onSaveDraft"
>
{{ 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">{{ t('myListings.title') }}</h1>
<UButton color="primary" to="/cabinet/products/new">
{{ 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"
>
{{ 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">{{ t('myListings.empty') }}</p>
<UButton color="primary" to="/cabinet/products/new">
{{ 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`"
>
{{ t('common.edit') }}
</UButton>
<UButton
v-if="product.status === 'draft'"
size="xs"
variant="outline"
color="error"
@click="deleteProduct(product.id)"
>
{{ 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">
{{ 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">{{ t('productDetail.notFound') }}</h1>
<p class="text-gray-500 mb-4">{{ t('productDetail.notFoundDescription') }}</p>
<UButton to="/catalog">{{ 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">{{ product.title }}</h1>
<p class="text-3xl font-bold mt-2">{{ formattedPrice }}</p>
<div class="flex gap-3 mt-4">
<UButton color="primary" size="lg" disabled>
{{ t('productDetail.contactSeller') }}
</UButton>
<UButton variant="outline" size="lg" disabled>
{{ t('product.showPhone') }}
</UButton>
</div>
<!-- Specs -->
<div class="mt-6">
<h3 class="font-semibold mb-2">{{ 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">{{ product.oem_number }}</span>
</div>
<div v-if="product.manufacturer" class="flex justify-between">
<span class="text-gray-500">{{ t('listing.manufacturer') }}</span>
<span class="font-medium">{{ product.manufacturer }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('listing.steering') }}</span>
<span class="font-medium">{{ 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">{{ 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"
>
{{ 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">{{ t('productDetail.description') }}</h3>
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-line">{{ 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">{{ product.seller.display_name }}</div>
<div class="text-sm text-gray-500">
{{ 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">
{{ 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">{{ 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">
{{ 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 paginationAdd 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"