Appearance
Partizap Frontend Architecture Design
Date: 2026-02-03 Status: Approved Backend: PHP Slim 4, PHP Sessions, cursor-based pagination, SERIAL IDs Deploy: VPS (Hetzner/Selectel), Node.js (Nitro) рядом с PHP
1. Tech Stack и ключевые решения
Runtime: Nuxt 4 (v4.3+) + TypeScript strict mode
Рендеринг: Hybrid — SSR для публичных страниц (главная, каталог, карточка товара, профиль продавца), SPA для приватных (кабинет, добавление объявления, настройки). Конфигурация через routeRules в nuxt.config.ts.
UI: Nuxt UI v3 (Tailwind 4 + Reka UI). Единая дизайн-система через app.config.ts — цвета, радиусы, типография.
State management: Pinia для глобального состояния (auth, geo, favorites), composables для локального (фильтры, формы, UI-состояние).
API-клиент: Обёртка над встроенным $fetch / useFetch. Автоматическая подстановка CSRF-токена из cookie в заголовок X-CSRF-TOKEN. Проброс session cookie при SSR через useRequestHeaders(['cookie']). Типизированные endpoint-функции в entities/*/api/.
Формы: Nuxt UI <UForm> + Zod-схемы. Схемы валидации определяются в entities/ и переиспользуются на Nitro-сервере для валидации.
Изображения: @nuxt/image для отображения (lazy loading, srcset, WebP/JPEG fallback через <NuxtPicture>). Кастомный useImageUpload composable для загрузки.
i18n: @nuxtjs/i18n — русский как основной, структура готова к добавлению языков.
SEO: @nuxtjs/seo — Schema.org (Product, Offer, LocalBusiness), динамический sitemap, robots.txt, OG-images.
Деплой: Node.js процесс (Nitro) на VPS рядом с PHP-бэкендом. PM2 или systemd для управления процессом.
2. Структура проекта (FSD-адаптированный под Nuxt)
partizap-frontend/
├── nuxt.config.ts
├── app.config.ts # Nuxt UI тема, цвета, брендинг
├── app.vue # Root layout
├── i18n/
│ └── locales/
│ └── ru.json
│
├── pages/ # Nuxt auto-routing (тонкие — layout + виджеты)
│ ├── index.vue # Главная
│ ├── catalog/
│ │ └── index.vue # Каталог с фильтрами
│ ├── product/
│ │ └── [id].vue # Карточка товара
│ ├── auth/
│ │ └── index.vue # Вход / Регистрация
│ ├── seller/
│ │ └── [id].vue # Профиль продавца
│ └── cabinet/ # SPA-зона (routeRules: ssr: false)
│ ├── index.vue # Мои объявления
│ ├── products/
│ │ ├── new.vue # Добавить объявление
│ │ └── [id]/
│ │ └── edit.vue # Редактировать
│ ├── favorites.vue
│ └── settings.vue # Настройки профиля
│
├── layouts/
│ ├── default.vue # Header + Footer (публичные)
│ └── cabinet.vue # Header + Sidebar (кабинет)
│
├── shared/ # FSD: переиспользуемое без бизнес-логики
│ ├── api/ # $fetch обёртка, CSRF, cursor pagination helpers
│ ├── ui/ # Базовые компоненты поверх Nuxt UI (если нужны обёртки)
│ ├── lib/ # Утилиты: formatPrice, formatDate, pluralize
│ └── config/ # Константы: статусы, категории, route names
│
├── entities/ # FSD: бизнес-сущности (данные + отображение)
│ ├── product/
│ │ ├── api/ # getProduct, getProducts, searchProducts
│ │ ├── model/ # типы: Product, ProductStatus, ProductFilters
│ │ ├── ui/ # ProductCard, ProductGallery, ProductSpecs
│ │ └── index.ts # Public API сущности
│ ├── user/
│ ├── category/
│ ├── car/ # Make, Model, Generation
│ └── geo/ # Region, City, District, Metro
│
├── features/ # FSD: пользовательские действия
│ ├── auth/ # login, register, logout, reset-password
│ ├── add-listing/ # Создание/редактирование объявления
│ ├── search/ # Полнотекстовый поиск + YMM-фильтр
│ ├── favorites/ # Добавить/удалить из избранного
│ ├── geo-select/ # Выбор города/региона
│ └── image-upload/ # Загрузка + сортировка фото
│
├── widgets/ # FSD: композиция entities + features
│ ├── header/ # Шапка: лого, поиск, гео, auth-статус
│ ├── footer/
│ ├── catalog-filters/ # Sidebar фильтров каталога
│ ├── product-list/ # Список товаров + cursor-пагинация
│ └── ymm-filter/ # Year/Make/Model каскадный фильтр
│
├── stores/ # Pinia глобальные сторы
│ ├── auth.ts
│ ├── geo.ts
│ └── favorites.ts
│
└── server/ # Nitro server routes (при необходимости)
└── api/ # Proxy к PHP API или SSR-хелперыКлючевые правила FSD:
shared— не знает о бизнесеentities— знает только оshared, не импортируетfeatures/widgetsfeatures— импортируетentities+sharedwidgets— импортирует всё вышестоящееpages— тонкие, только композиция виджетов +definePageMeta
Авто-импорт в nuxt.config.ts: директории shared/ui, shared/lib, entities/*/ui, features/*/ui, widgets/*/ регистрируются через components.dirs и imports.dirs.
3. API-слой и взаимодействие с PHP-бэкендом
Базовый клиент (shared/api/) — обёртка над $fetch с тремя задачами:
CSRF: Читает
CSRF_TOKENиз cookie, ставит в заголовокX-CSRF-TOKENна каждый мутирующий запрос (POST/PUT/PATCH/DELETE). На GET не отправляется.Session cookie при SSR: При серверном рендеринге браузерные cookies недоступны — Nitro-сервер должен прокинуть
cookieиз входящего запроса пользователя в исходящий запрос к PHP API черезuseRequestHeaders(['cookie']). ОтветныеSet-Cookieот PHP тоже пробрасываются обратно клиенту черезuseRequestEvent().Cursor pagination: Хелпер
useCursorPagination— принимает endpoint и фильтры, возвращает реактивныеitems,hasMore,loadMore(),refresh(). Под капотом передаётcursorпараметр из предыдущего ответа.
Типизированные API-функции живут в entities/*/api/:
entities/product/api/
├── getProducts.ts # GET /store/products — список с фильтрами
├── getProduct.ts # GET /store/products/:id — детали
├── searchProducts.ts # GET /store/products/search — FTS
├── createProduct.ts # POST /vendor/products
├── updateProduct.ts # PUT /vendor/products/:id
└── index.ts # Re-export public APIКаждая функция — тонкая обёртка: принимает типизированные параметры, вызывает базовый клиент, возвращает типизированный ответ. Zod-схемы ответов опциональны на MVP (доверяем бэкенду), но структура готова к добавлению runtime-валидации.
Формат ответа от PHP API (из MVP design):
typescript
// Успех (список)
{ data: Product[], meta: { has_more: boolean, next_cursor: string | null } }
// Успех (один объект)
{ data: Product }
// Ошибка
{ error: { code: string, message: string, details?: Record<string, string[]> } }Error handling: Базовый клиент перехватывает HTTP-ошибки: 401 → редирект на auth, 403 → уведомление, 422 → пробрасывает details в форму, 429 → уведомление о rate limit, 500 → общая ошибка.
4. Routing, Route Rules и навигация
Hybrid rendering через routeRules в nuxt.config.ts:
SSR (server-rendered):
/ → Главная — SEO, FCP
/catalog/** → Каталог — индексация фильтрованных страниц
/product/** → Карточка товара — rich snippets, OG
/seller/** → Профиль продавца — индексация
/auth → Авторизация — быстрая загрузка
SPA (client-only, ssr: false):
/cabinet/** → Весь личный кабинет — не нужен SEO, быстрее навигация
ISR/SWR (кандидаты на будущее):
/store/categories → Дерево категорий — редко меняется, можно кэшировать
/store/cars/makes → Марки авто — статичные данныеНавигация и guards:
Middleware разделены на два уровня:
auth.global.ts— глобальный, на каждый переход: проверяет наличие сессии, подгружаетauthStore.userесли не загружен (один запросGET /auth/meпри первом визите). Не блокирует публичные страницы.cabinet.ts— named middleware наpages/cabinet/**: редирект на/authесли не авторизован, проверкаemail_verified_atдля создания объявлений.
URL-структура (SERIAL IDs, чистые URL):
/ Главная
/catalog Каталог (все)
/catalog?make=1&model=5 Каталог с фильтрами (query params)
/product/123 Карточка товара
/seller/45 Профиль продавца
/auth Вход / Регистрация (табы)
/auth?redirect=/cabinet/products/new Редирект после входа
/cabinet Мои объявления
/cabinet/products/new Добавить объявление
/cabinet/products/123/edit Редактировать
/cabinet/favorites Избранное
/cabinet/settings Настройки профиляФильтры каталога хранятся в query params (не в store) — это даёт shareable URLs, работу кнопки «назад», SEO для фильтрованных страниц. Composable useCatalogFilters синхронизирует query params <-> реактивное состояние фильтров <-> API-запросы.
5. Pinia сторы и composables — разделение ответственности
Три глобальных стора:
authStore — сессия пользователя:
- State:
user(id, email, display_name, account_type, is_admin, verified flags),isAuthenticated - Actions:
fetchMe()(GET /auth/me),login(),register(),logout(),logoutAll() - Инициализация: вызывается из
auth.global.tsmiddleware при первом визите. При SSR — запрос к PHP API с проброшенными cookies. Результат гидратируется на клиент, повторный запрос не нужен.
geoStore — выбранный город/регион:
- State:
currentCity,currentRegion,searchRadius - Actions:
setCity(),detectByIP(),loadRegions(),loadCities(regionId) - Персистенция: cookie
geo_city_id(доступна и на сервере при SSR, и на клиенте). При первом визите: читаем cookie → если пусто, определяем по IP → если не удалось, «Все регионы». Для залогиненного пользователя: синхронизация сusers.city_idиз профиля (по ТЗ — при смене города в хедере спрашиваем «Сохранить в профиль?»).
favoritesStore — избранные товары:
- State:
favoriteIds(Set<number>),count - Actions:
toggle(productId),loadFavorites(),isFavorite(productId) - Для авторизованных: синхронизация с сервером. Для гостей:
localStorage(при регистрации — merge с сервером, deferred для post-MVP).
Composables (локальное состояние):
| Composable | Область | Что делает |
|---|---|---|
useCatalogFilters | Каталог | Синхронизация query params <-> фильтры <-> API |
useCursorPagination | Каталог, списки | items, hasMore, loadMore(), refresh() |
useYmmFilter | Главная, каталог | Каскадный выбор Make → Model → Generation |
useImageUpload | Добавить объявление | Превью, drag-and-drop, сортировка, загрузка |
useProductForm | Добавить/редактировать | Состояние формы, Zod-валидация, submit |
usePhoneReveal | Карточка товара | Ленивая загрузка телефона продавца по клику |
Правило границы: если состояние нужно на 2+ несвязанных страницах — Pinia store. Если привязано к одному экрану или компоненту — composable.
6. SEO, Schema.org и мета-данные
Стратегия по страницам:
Карточка товара (/product/:id) — главный приоритет SEO:
useSeoMeta: title ={title} — купить в {city} | Partizap, description из первых 160 символов описания, OG-image из primary фото- Schema.org:
Product+Offer(price, availability, condition) +LocalBusiness(продавец). Это даёт rich snippets в Google: фото, цена, наличие - Canonical URL:
/product/{id}
Каталог (/catalog):
- Динамический title по активным фильтрам: «Запчасти BMW X5 E53 в СПб» →
{категория} {марка} {модель} {поколение} в {город} - Schema.org:
ItemListс позициями - Фильтры в query params индексируются (make + model комбинации), остальные —
noindexчерез meta robots для предотвращения дублей
Главная (/):
- Статичный title + description, Schema.org:
WebSite+SearchAction(для sitelinks searchbox в Google)
Профиль продавца (/seller/:id):
- Schema.org:
LocalBusinessилиPersonв зависимости отaccount_type
Кабинет (/cabinet/**):
noindex, nofollow— приватные страницы не индексируются
Sitemap (@nuxtjs/sitemap):
- Динамический: endpoint в
server/api/__sitemap__/запрашивает у PHP API список активных товаров и продавцов сupdated_atдляlastmod - Разбивка на sitemap index если >50K URL
- Приоритет: карточки товаров > каталог по маркам > профили продавцов
Robots.txt:
User-agent: *
Allow: /
Disallow: /cabinet/
Disallow: /auth
Sitemap: https://partizap.ru/sitemap.xml7. Обработка изображений и медиа
Отображение (@nuxt/image):
Провайдер — кастомный или ipx проксирующий к S3/CDN. Бэкенд генерирует 3 размера в двух форматах (из MVP design):
| Размер | Назначение | Где используется |
|---|---|---|
thumbnail (150x150) | Превью в списках, сетка каталога | ProductCard, избранное |
medium (600x600) | Превью в галерее карточки | ProductGallery thumbnails |
large (1200x1200) | Полноразмерное фото | ProductGallery основное фото |
Компонент <NuxtPicture> отдаёт WebP с JPEG fallback:
html
<NuxtPicture
:src="image.thumbnail_webp"
:fallback="image.thumbnail_jpeg"
loading="lazy"
sizes="150px sm:200px"
/>Загрузка (features/image-upload/):
Composable useImageUpload инкапсулирует полный флоу:
Выбор файлов: input[type=file] + drag-and-drop зона. Ограничения: max 5 файлов, JPEG/PNG/WebP, до 10MB каждый. Валидация на клиенте до отправки.
Превью:
URL.createObjectURL()— мгновенное отображение без загрузки на сервер. Освобождение черезURL.revokeObjectURL()при удалении или unmount.Загрузка: Каждый файл отправляется отдельным
POST /vendor/products/:id/imagesсFormData. Прогресс черезxhr.upload.onprogress(ofetch не поддерживает upload progress — для этого случая прямойXMLHttpRequest).Статус обработки: Бэкенд возвращает
status: 'processing'. Клиент поллитGET /vendor/products/:idпока все изображения не получатstatus: 'ready'. Показываем спиннер на необработанных фото.Сортировка: Drag-and-drop переупорядочивание, сохранение через
PUT /vendor/products/:id/images/orderс массивом[imageId, ...].Удаление:
DELETE /vendor/products/:id/images/:imgIdс optimistic update в UI.
8. Зависимости
Core
| Пакет | Версия | Назначение |
|---|---|---|
nuxt | ^3.15 | Фреймворк |
vue | ^3.5 | (peer dependency) |
typescript | ^5.7 | Strict mode |
Nuxt Modules
| Модуль | Назначение |
|---|---|
@nuxt/ui | UI-компоненты + Tailwind 4 + Reka UI |
@nuxt/image | Оптимизация изображений, <NuxtPicture> |
@nuxtjs/i18n | Интернационализация |
@nuxtjs/seo | Schema.org, sitemap, robots, OG |
@pinia/nuxt | State management |
pinia | (peer dependency) |
Библиотеки
| Пакет | Назначение |
|---|---|
zod | Валидация форм и API-ответов |
@vueuse/core | Утилитарные composables (useStorage, useDebounceFn, useIntersectionObserver) |
Dev Dependencies
| Пакет | Назначение |
|---|---|
@nuxt/test-utils | Тестирование Nuxt-компонентов |
vitest | Test runner |
@vue/test-utils | Unit-тесты компонентов |
eslint | Линтинг |
@nuxt/eslint | ESLint конфиг для Nuxt |
prettier | Форматирование |
Сознательно НЕ включены
| Пакет | Причина |
|---|---|
axios | Дублирует встроенный $fetch / ofetch |
vee-validate | Nuxt UI Form + Zod покрывает потребности |
pinia-plugin-persistedstate | Для auth — cookies вручную, для geo — cookie через useCookie, для favorites — useLocalStorage из VueUse |
@nuxtjs/color-mode | Входит в Nuxt UI |
tailwindcss | Входит в Nuxt UI v3 |
Итого: 8 runtime-зависимостей, 6 dev-зависимостей.