Appearance
Listings CRUD — End-to-End Design
Date: 2026-02-08 Status: Approved Scope: Full CRUD for listings (cabinet) + catalog + product detail (public)
Overview
Реализуем полный цикл работы с объявлениями автозапчастей:
- Cabinet: создание, редактирование, управление объявлениями (vendor API)
- Public: каталог с фильтрами + карточка товара (store API)
Авторизация уже работает. Все бэкенд-эндпоинты зарегистрированы и отвечают.
Contract Decisions (из обсуждения с бэком)
- Geo paths: бэковые
/store/geo/regions,/store/geo/cities/:id/... - Vendor profile:
GET/PUT /vendor/me - Error format (frontend):
{ error: { code, message, details? } }(nested)details—Record<string, string[]>для валидационных ошибок по полям
Architecture (FSD)
New/Extended Entities
entities/product/ — расширяем:
model/product.schema.ts— полные типы (detail, image, compatibility, form)ui/ProductCard.vue— карточка товара (каталог, мои объявления, профиль продавца)ui/ProductStatusBadge.vue— бейдж статуса
New Features
features/product-form/ — форма создания/редактирования:
composables/useProductForm.ts— state management, validation, submitui/ProductForm.vue— компонент формы (mode: create | edit)
features/image-upload/ — загрузка фотографий:
composables/useImageUpload.ts— upload, reorder, delete, processing trackingui/ImageUploader.vue— drag & drop UI, preview, status indicators
features/ymm-select/ — каскадный выбор авто:
composables/useYmmCascade.ts— loads makes → models → generationsui/YmmSelect.vue— cascading selects with "add another" support
features/geo-select/ — каскадный выбор геолокации:
composables/useGeoCascade.ts— loads regions → cities → districts → metroui/GeoSelect.vue— cascading selects
New Widgets
widgets/my-products/ — список моих объявлений с табами по статусу widgets/product-list/ — список товаров каталога с фильтрами + пагинация
Pages (fill existing stubs)
cabinet/index.vue— мои объявленияcabinet/products/new.vue— созданиеcabinet/products/[id]/edit.vue— редактированиеcatalog/index.vue— каталог с фильтрамиproduct/[id].vue— карточка товара (SSR)
Product Form — Fields & Validation
| Field | Type | Source | Validation |
|---|---|---|---|
| Категория (part) | Cascading select | GET /store/categories (type=part) | 1+ required |
| Состояние (condition) | Radio group | GET /store/categories (type=condition) | Exactly 1 |
| Название | Input | — | 3-255 chars |
| Фотографии | Image upload | POST /vendor/products/{id}/images | 0+ (recommend 1+) |
| Совместимость | YMM cascade (multiple) | cars/makes → models → generations | 0+ entries |
| OEM номер | Input | — | Max 50 chars |
| Производитель | Input | — | Max 100 chars |
| Руль (steering) | Radio | enum: left/right/universal | Default: universal |
| Описание | Textarea | — | Max 10000 chars |
| Цена | Number input | — | > 0, max 99999999.99 |
| Регион → Город → Район | Geo cascade | store/geo/* | City required |
| Адрес | Input | — | Optional, max 255 |
Create Flow
- User fills form → clicks "Сохранить черновик"
POST /vendor/products→ creates product with statusdraft, returnsid- Photo upload:
POST /vendor/products/{id}/images(multipart) — one file at a time - Photos processed async (status: processing → ready)
- "Опубликовать" →
POST /vendor/products/{id}/publish→ draft → pending - Admin on backend: pending → active or rejected
Auto-save Draft for Photos (Avito-style)
When user adds first photo before saving:
- Auto-create draft (
POST /vendor/productswith minimal data: title placeholder) - Get
id, upload photo immediately - Form stays in create mode, user continues filling
- Final submit →
PUT /vendor/products/{id}updates the draft
Edit Flow
GET /vendor/products/{id}→ pre-fill formPUT /vendor/products/{id}→ update text fields- Photos: add new, delete old, reorder
- Only available for draft/rejected status
Product Schema Extension
ts
// Full product from store/products/{id}
productImageSchema = z.object({
id: z.number(),
thumbnail_webp: z.string().nullable(),
thumbnail_jpeg: z.string().nullable(),
medium_webp: z.string().nullable(),
medium_jpeg: z.string().nullable(),
large_webp: z.string().nullable(),
large_jpeg: z.string().nullable(),
status: z.enum(['processing', 'ready', 'error']),
is_primary: z.boolean(),
sort_order: z.number(),
})
compatibilitySchema = z.object({
make_id: z.number(),
model_id: z.number().nullable(),
generation_id: z.number().nullable(),
note: z.string().nullable(),
})
productOemSchema = z.object({
oem_number_id: z.number(),
is_primary: z.boolean(),
})
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),
seller: sellerPublicSchema,
})
// Form validation schema
productFormSchema = z.object({
title: z.string().min(3).max(255),
description: z.string().max(10000).optional(),
price: z.number().positive().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(z.object({
category_id: z.number(),
is_primary: z.boolean(),
})).min(1),
condition_category_id: z.number().positive(),
compatibility: z.array(compatibilitySchema).default([]),
})API Client Extension
Add upload() method to shared/api/client.ts for multipart form-data:
ts
upload: <T>(url: string, formData: FormData) =>
request<T>(url, {
method: 'POST',
body: formData,
// Do NOT set Content-Type — browser sets multipart boundary automatically
})Catalog Page
Filters (URL query params for SEO)
category_id— tree select (parent → children)make_id,model_id,generation_id— YMM cascadeprice_min,price_max— range inputssteering— radio: all / left / right / universalregion_id,city_id— geo cascadesort— select: date_desc (default), price_asc, price_desc
URL example: /catalog?category_id=5&make_id=1&price_min=1000&sort=price_asc
Layout
- Desktop: filters sidebar (left) + product grid (right)
- Mobile: filter button → bottom sheet / modal
- Cursor pagination: "Показать ещё" button
- Uses
useCursorPaginationcomposable
Product Detail Page
SSR-rendered for SEO. useAsyncData → GET /store/products/{id}.
Layout (from wireframes)
- Left: Gallery (main image + thumbnails carousel)
- Right: Title, price, contact buttons (stubs — no chat yet), specs table (OEM, condition, manufacturer, steering), compatibility list, description, seller card
SEO
useSeoMeta() with title, description, og:image from primary image.
Error Handling
| Status | Scenario | Action |
|---|---|---|
| 422 | Validation errors | Map error.details to form fields via useApiError |
| 401 | Not authenticated | Redirect to /auth/login (existing useApi behavior) |
| 403 | Email not verified / not owner | Toast + link to verify-email or back |
| 404 | Product not found / not owned | Show error page |
| 413 | Image too large (>10MB) | Toast with size limit info |
Implementation Phases
Phase 1 — Foundation (no dependencies)
- Extend
product.schema.ts(full types: detail, image, compatibility, form) - Add
upload()method toshared/api/client.tsfor multipart - Create
features/ymm-select/— composable + UI (data from store/cars/*) - Create
features/geo-select/— composable + UI (data from store/geo/*)
Phase 2 — Cabinet CRUD (depends on Phase 1)
features/image-upload/— composable + UIfeatures/product-form/— composable + component (uses ymm-select, geo-select, image-upload)cabinet/products/new.vue— create page (product-form mode=create)cabinet/products/[id]/edit.vue— edit page (mode=edit, pre-fill)cabinet/index.vue— "Мои объявления" (list, status filter, actions)
Phase 3 — Public Pages (depends on Phase 1)
entities/product/ui/ProductCard.vue— reusable product cardentities/product/ui/ProductStatusBadge.vue— status badgeproduct/[id].vue— product detail (SSR, SEO)catalog/index.vue— catalog with filters and pagination
Phase 4 — Polish & Verify
- Test
POST /vendor/productsfrom authorized user (check phone_verified_at blocker) - Test image upload (verify S3 on dev)
- i18n keys for new UI text
- Responsive design verification (mobile/desktop)
Deferred (not in scope)
- Seller profile page (
/seller/[id]) — stub exists - Favorites UI — store + endpoints ready, UI later
- Profile settings (
/cabinet/settings) — vendor/me PUT, later - Full-text search (
/store/products/search) — later - Phone verification — waiting for backend SMS support
- Chat/messaging — deferred in MVP
Risks
- S3 on dev: image upload needs S3 configured — will test early
- phone_verified_at: backend may block product creation — will test and report
- Categories type=attribute: 0 in DB — not blocking (steering is a product field)
- Image processing: async on backend — need polling or webhook for status updates