Skip to content

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)
    • detailsRecord<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, submit
  • ui/ProductForm.vue — компонент формы (mode: create | edit)

features/image-upload/ — загрузка фотографий:

  • composables/useImageUpload.ts — upload, reorder, delete, processing tracking
  • ui/ImageUploader.vue — drag & drop UI, preview, status indicators

features/ymm-select/ — каскадный выбор авто:

  • composables/useYmmCascade.ts — loads makes → models → generations
  • ui/YmmSelect.vue — cascading selects with "add another" support

features/geo-select/ — каскадный выбор геолокации:

  • composables/useGeoCascade.ts — loads regions → cities → districts → metro
  • ui/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

FieldTypeSourceValidation
Категория (part)Cascading selectGET /store/categories (type=part)1+ required
Состояние (condition)Radio groupGET /store/categories (type=condition)Exactly 1
НазваниеInput3-255 chars
ФотографииImage uploadPOST /vendor/products/{id}/images0+ (recommend 1+)
СовместимостьYMM cascade (multiple)cars/makes → models → generations0+ entries
OEM номерInputMax 50 chars
ПроизводительInputMax 100 chars
Руль (steering)Radioenum: left/right/universalDefault: universal
ОписаниеTextareaMax 10000 chars
ЦенаNumber input> 0, max 99999999.99
Регион → Город → РайонGeo cascadestore/geo/*City required
АдресInputOptional, max 255

Create Flow

  1. User fills form → clicks "Сохранить черновик"
  2. POST /vendor/products → creates product with status draft, returns id
  3. Photo upload: POST /vendor/products/{id}/images (multipart) — one file at a time
  4. Photos processed async (status: processing → ready)
  5. "Опубликовать" → POST /vendor/products/{id}/publish → draft → pending
  6. Admin on backend: pending → active or rejected

Auto-save Draft for Photos (Avito-style)

When user adds first photo before saving:

  1. Auto-create draft (POST /vendor/products with minimal data: title placeholder)
  2. Get id, upload photo immediately
  3. Form stays in create mode, user continues filling
  4. Final submit → PUT /vendor/products/{id} updates the draft

Edit Flow

  1. GET /vendor/products/{id} → pre-fill form
  2. PUT /vendor/products/{id} → update text fields
  3. Photos: add new, delete old, reorder
  4. 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 cascade
  • price_min, price_max — range inputs
  • steering — radio: all / left / right / universal
  • region_id, city_id — geo cascade
  • sort — 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 useCursorPagination composable

Product Detail Page

SSR-rendered for SEO. useAsyncDataGET /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

StatusScenarioAction
422Validation errorsMap error.details to form fields via useApiError
401Not authenticatedRedirect to /auth/login (existing useApi behavior)
403Email not verified / not ownerToast + link to verify-email or back
404Product not found / not ownedShow error page
413Image too large (>10MB)Toast with size limit info

Implementation Phases

Phase 1 — Foundation (no dependencies)

  1. Extend product.schema.ts (full types: detail, image, compatibility, form)
  2. Add upload() method to shared/api/client.ts for multipart
  3. Create features/ymm-select/ — composable + UI (data from store/cars/*)
  4. Create features/geo-select/ — composable + UI (data from store/geo/*)

Phase 2 — Cabinet CRUD (depends on Phase 1)

  1. features/image-upload/ — composable + UI
  2. features/product-form/ — composable + component (uses ymm-select, geo-select, image-upload)
  3. cabinet/products/new.vue — create page (product-form mode=create)
  4. cabinet/products/[id]/edit.vue — edit page (mode=edit, pre-fill)
  5. cabinet/index.vue — "Мои объявления" (list, status filter, actions)

Phase 3 — Public Pages (depends on Phase 1)

  1. entities/product/ui/ProductCard.vue — reusable product card
  2. entities/product/ui/ProductStatusBadge.vue — status badge
  3. product/[id].vue — product detail (SSR, SEO)
  4. catalog/index.vue — catalog with filters and pagination

Phase 4 — Polish & Verify

  1. Test POST /vendor/products from authorized user (check phone_verified_at blocker)
  2. Test image upload (verify S3 on dev)
  3. i18n keys for new UI text
  4. 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