Appearance
Partizap Frontend Initialization — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Initialize a production-ready Nuxt 4 project with FSD architecture, Nuxt UI v4, Pinia, i18n, SEO, and a typed API client for the PHP Slim 4 backend.
Architecture: Nuxt 4 with hybrid rendering (SSR for public pages, SPA for cabinet). FSD-adapted structure via nuxt-fsd module. Typed $fetch wrapper for PHP Sessions + CSRF + cursor-based pagination.
Tech Stack: Nuxt 4.3, Nuxt UI v4, Pinia, Zod, @nuxtjs/i18n, @nuxtjs/seo, @nuxt/image, @vueuse/core, nuxt-fsd, Vitest
Design doc: docs/plans/2026-02-03-frontend-architecture-design.md
Task 1: Scaffold Nuxt 4 project
Files:
- Create:
partizap-frontend/(entire project scaffold) - Create:
partizap-frontend/.npmrc
Step 1: Create project
bash
cd /Users/dmitriy/Documents/JOB/partizap
npx nuxi@latest init partizap-frontendSelect default options when prompted.
Step 2: Configure pnpm (if using pnpm)
Create .npmrc:
shamefully-hoist=trueStep 3: Install dependencies and verify
bash
cd partizap-frontend
npm install
npm run devExpected: dev server starts at http://localhost:3000, default Nuxt welcome page loads.
Step 4: Enable strict TypeScript
Edit partizap-frontend/nuxt.config.ts:
ts
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
typescript: {
strict: true,
typeCheck: true,
},
})Step 5: Verify TypeScript
bash
npx nuxi typecheckExpected: no type errors.
Step 6: Initialize git and commit
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
git init
git add -A
git commit -m "chore: scaffold Nuxt 4 project with strict TypeScript"Task 2: Install Nuxt UI v4
Files:
- Modify:
partizap-frontend/nuxt.config.ts - Create:
partizap-frontend/app/assets/css/main.css - Modify:
partizap-frontend/app/app.vue
Step 1: Install packages
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npm install @nuxt/ui tailwindcssStep 2: Register module in nuxt.config.ts
ts
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
typescript: {
strict: true,
typeCheck: true,
},
})Step 3: Create main.css
Create app/assets/css/main.css:
css
@import "tailwindcss";
@import "@nuxt/ui";Step 4: Update app.vue
Replace content of app/app.vue:
html
<template>
<UApp>
<NuxtPage />
</UApp>
</template>Step 5: Create test page
Create app/pages/index.vue:
html
<template>
<div class="p-8">
<h1 class="text-2xl font-bold">Partizap</h1>
<UButton label="Test Button" />
</div>
</template>Step 6: Verify
bash
npm run devExpected: page renders with styled heading and Nuxt UI button at http://localhost:3000.
Step 7: Commit
bash
git add -A
git commit -m "feat: add Nuxt UI v4 with Tailwind CSS"Task 3: Setup FSD structure with nuxt-fsd
Files:
- Modify:
partizap-frontend/nuxt.config.ts - Create: FSD directories under
app/
Step 1: Install nuxt-fsd
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npx nuxi module add nuxt-fsdStep 2: Configure FSD in nuxt.config.ts
ts
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
modules: ['@nuxt/ui', 'nuxt-fsd'],
css: ['~/assets/css/main.css'],
fsd: {
layers: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
segments: ['ui', 'model', 'api', 'lib', 'config'],
},
typescript: {
strict: true,
typeCheck: true,
},
})Step 3: Create FSD directory skeleton
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend/app
# shared (no slices)
mkdir -p shared/{ui,api,lib,config}
# entities
mkdir -p entities/product/{ui,model,api}
mkdir -p entities/user/{ui,model,api}
mkdir -p entities/category/{ui,model,api}
mkdir -p entities/car/{ui,model,api}
mkdir -p entities/geo/{ui,model,api}
# features
mkdir -p features/auth/{ui,model,api}
mkdir -p features/add-listing/{ui,model}
mkdir -p features/search/{ui,model}
mkdir -p features/favorites/{ui,model}
mkdir -p features/geo-select/{ui,model}
mkdir -p features/image-upload/{ui,model}
# widgets
mkdir -p widgets/header/ui
mkdir -p widgets/footer/ui
mkdir -p widgets/catalog-filters/ui
mkdir -p widgets/product-list/ui
mkdir -p widgets/ymm-filter/uiStep 4: Add .gitkeep to empty dirs
bash
find /Users/dmitriy/Documents/JOB/partizap/partizap-frontend/app/shared -type d -empty -exec touch {}/.gitkeep \;
find /Users/dmitriy/Documents/JOB/partizap/partizap-frontend/app/entities -type d -empty -exec touch {}/.gitkeep \;
find /Users/dmitriy/Documents/JOB/partizap/partizap-frontend/app/features -type d -empty -exec touch {}/.gitkeep \;
find /Users/dmitriy/Documents/JOB/partizap/partizap-frontend/app/widgets -type d -empty -exec touch {}/.gitkeep \;Step 5: Verify FSD auto-imports work
Create app/shared/ui/AppLogo.vue:
html
<template>
<span class="text-xl font-bold text-primary">Partizap</span>
</template>Update app/pages/index.vue:
html
<template>
<div class="p-8">
<SharedAppLogo />
<UButton label="Test Button" class="mt-4" />
</div>
</template>Run: npm run dev
Expected: <SharedAppLogo /> auto-imported and renders without explicit import.
Step 6: Commit
bash
git add -A
git commit -m "feat: add FSD directory structure with nuxt-fsd module"Task 4: Install and configure Pinia
Files:
- Modify:
partizap-frontend/nuxt.config.ts - Create:
partizap-frontend/app/stores/auth.ts
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npm install pinia @pinia/nuxtStep 2: Register module
Add '@pinia/nuxt' to modules in nuxt.config.ts:
ts
modules: ['@nuxt/ui', 'nuxt-fsd', '@pinia/nuxt'],Step 3: Create auth store skeleton
Create app/stores/auth.ts:
ts
import { defineStore } from 'pinia'
interface User {
id: number
email: string
display_name: string | null
account_type: 'personal' | 'business'
is_admin: boolean
email_verified_at: string | null
phone_verified_at: string | null
avatar_url: string | null
}
interface AuthState {
user: User | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
user: null,
}),
getters: {
isAuthenticated: (state) => state.user !== null,
isEmailVerified: (state) => state.user?.email_verified_at !== null,
isAdmin: (state) => state.user?.is_admin === true,
},
actions: {
setUser(user: User | null) {
this.user = user
},
clear() {
this.user = null
},
},
})Step 4: Verify Pinia works
Update app/pages/index.vue:
html
<template>
<div class="p-8">
<SharedAppLogo />
<p class="mt-4">Auth: {{ authStore.isAuthenticated ? 'Yes' : 'No' }}</p>
<UButton label="Test Button" class="mt-4" />
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
</script>Run: npm run dev
Expected: page shows "Auth: No", no errors. useAuthStore is auto-imported.
Step 5: Commit
bash
git add -A
git commit -m "feat: add Pinia with auth store skeleton"Task 5: Configure i18n
Files:
- Modify:
partizap-frontend/nuxt.config.ts - Create:
partizap-frontend/app/i18n/locales/ru.json - Create:
partizap-frontend/app/i18n/i18n.config.ts
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npx nuxi module add @nuxtjs/i18nStep 2: Create locale file
Create app/i18n/locales/ru.json:
json
{
"common": {
"appName": "Partizap",
"search": "Найти запчасть",
"login": "Войти",
"register": "Регистрация",
"logout": "Выйти",
"favorites": "Избранное",
"addListing": "Продать запчасть",
"allRegions": "Все регионы",
"loadMore": "Загрузить ещё",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"back": "Назад",
"close": "Закрыть"
},
"auth": {
"email": "Email",
"password": "Пароль",
"rememberMe": "Запомнить меня",
"forgotPassword": "Забыли пароль?",
"noAccount": "Нет аккаунта?",
"hasAccount": "Уже есть аккаунт?"
},
"product": {
"price": "Цена",
"condition": "Состояние",
"showPhone": "Показать телефон",
"addToFavorites": "В избранное",
"removeFromFavorites": "Убрать из избранного",
"views": "Просмотры",
"published": "Опубликовано"
},
"catalog": {
"filters": "Фильтры",
"sort": "Сортировка",
"sortByDate": "По дате",
"sortByPriceAsc": "Сначала дешёвые",
"sortByPriceDesc": "Сначала дорогие",
"resetFilters": "Сбросить фильтры",
"noResults": "Ничего не найдено"
},
"ymm": {
"make": "Марка",
"model": "Модель",
"generation": "Поколение",
"year": "Год",
"selectMake": "Выберите марку",
"selectModel": "Выберите модель",
"selectGeneration": "Выберите поколение"
}
}Step 3: Create i18n config
Create app/i18n/i18n.config.ts:
ts
import ru from './locales/ru.json'
export default defineI18nConfig(() => ({
legacy: false,
locale: 'ru',
fallbackLocale: 'ru',
messages: { ru },
}))Step 4: Configure module in nuxt.config.ts
Add to nuxt.config.ts:
ts
i18n: {
defaultLocale: 'ru',
locales: [{ code: 'ru', name: 'Русский' }],
vueI18n: './app/i18n/i18n.config.ts',
},Step 5: Verify
Update app/pages/index.vue:
html
<template>
<div class="p-8">
<SharedAppLogo />
<p class="mt-4">{{ $t('common.appName') }}</p>
<UButton :label="$t('common.search')" class="mt-4" />
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
</script>Run: npm run dev
Expected: page shows "Partizap" and button "Найти запчасть" from locale file.
Step 6: Commit
bash
git add -A
git commit -m "feat: add i18n with Russian locale"Task 6: Configure SEO module
Files:
- Modify:
partizap-frontend/nuxt.config.ts
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npx nuxi module add @nuxtjs/seoStep 2: Configure in nuxt.config.ts
Add site config and robots settings:
ts
site: {
url: 'https://partizap.ru',
name: 'Partizap',
description: 'Маркетплейс автозапчастей в Санкт-Петербурге',
defaultLocale: 'ru',
},
robots: {
disallow: ['/cabinet/', '/auth'],
},Step 3: Add SEO meta to index page
Update app/pages/index.vue:
html
<script setup lang="ts">
const authStore = useAuthStore()
useSeoMeta({
title: 'Partizap — маркетплейс автозапчастей в СПб',
description: 'Купить и продать автозапчасти в Санкт-Петербурге. Поиск по марке, модели и OEM номеру.',
ogTitle: 'Partizap — маркетплейс автозапчастей',
ogDescription: 'Запчасти для любых автомобилей в Санкт-Петербурге',
})
</script>
<template>
<div class="p-8">
<SharedAppLogo />
<p class="mt-4">{{ $t('common.appName') }}</p>
<UButton :label="$t('common.search')" class="mt-4" />
</div>
</template>Step 4: Verify
bash
npm run devOpen http://localhost:3000 and inspect page source. Expected: <title> and <meta> tags rendered in HTML.
Check http://localhost:3000/robots.txt. Expected: Disallow: /cabinet/ and Disallow: /auth.
Step 5: Commit
bash
git add -A
git commit -m "feat: add SEO module with site config and robots"Task 7: Configure @nuxt/image
Files:
- Modify:
partizap-frontend/nuxt.config.ts
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npx nuxi module add @nuxt/imageStep 2: Configure in nuxt.config.ts
Add '@nuxt/image' to modules. Add image config:
ts
image: {
quality: 80,
formats: ['webp', 'jpeg'],
},Step 3: Verify
Add to app/pages/index.vue template:
html
<NuxtImg src="/icon.png" alt="test" width="64" height="64" class="mt-4" />Run: npm run dev
Expected: image renders (even if placeholder), no module errors in console.
Remove the test <NuxtImg> after verification.
Step 4: Commit
bash
git add -A
git commit -m "feat: add @nuxt/image module"Task 8: Install VueUse and Zod
Files:
- Modify:
partizap-frontend/package.json(via npm install)
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npm install @vueuse/core @vueuse/nuxt zodStep 2: Register VueUse module
Add '@vueuse/nuxt' to modules in nuxt.config.ts.
Step 3: Verify VueUse
Add to app/pages/index.vue:
html
<script setup lang="ts">
const authStore = useAuthStore()
const { width } = useWindowSize()
useSeoMeta({
title: 'Partizap — маркетплейс автозапчастей в СПб',
description: 'Купить и продать автозапчасти в Санкт-Петербурге.',
})
</script>
<template>
<div class="p-8">
<SharedAppLogo />
<p class="mt-4">{{ $t('common.appName') }} — viewport: {{ width }}px</p>
<UButton :label="$t('common.search')" class="mt-4" />
</div>
</template>Run: npm run dev
Expected: viewport width shown on page, updates on resize.
Step 4: Verify Zod
Create app/entities/product/model/product.schema.ts:
ts
import { z } from 'zod'
export const productStatusSchema = z.enum([
'draft',
'pending',
'active',
'sold',
'archived',
'rejected',
])
export const steeringSchema = z.enum(['left', 'right', 'universal'])
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 type Product = z.infer<typeof productSchema>
export type ProductStatus = z.infer<typeof productStatusSchema>
export type Steering = z.infer<typeof steeringSchema>Run: npx nuxi typecheck
Expected: no type errors.
Step 5: Commit
bash
git add -A
git commit -m "feat: add VueUse, Zod, and product schema"Task 9: Setup routing and layouts
Files:
- Create:
app/layouts/default.vue - Create:
app/layouts/cabinet.vue - Modify:
app/pages/index.vue - Create:
app/pages/catalog/index.vue - Create:
app/pages/product/[id].vue - Create:
app/pages/auth/index.vue - Create:
app/pages/seller/[id].vue - Create:
app/pages/cabinet/index.vue - Create:
app/pages/cabinet/products/new.vue - Create:
app/pages/cabinet/products/[id]/edit.vue - Create:
app/pages/cabinet/favorites.vue - Create:
app/pages/cabinet/settings.vue - Modify:
partizap-frontend/nuxt.config.ts
Step 1: Configure route rules in nuxt.config.ts
Add routeRules:
ts
routeRules: {
'/cabinet/**': { ssr: false },
},Step 2: Create default layout
Create app/layouts/default.vue:
html
<template>
<div class="min-h-screen flex flex-col">
<header class="border-b border-gray-200 dark:border-gray-800">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<NuxtLink to="/">
<SharedAppLogo />
</NuxtLink>
<nav class="flex items-center gap-4">
<UButton :label="$t('common.addListing')" to="/cabinet/products/new" variant="solid" />
<UButton :label="$t('common.login')" to="/auth" variant="ghost" />
</nav>
</div>
</header>
<main class="flex-1">
<slot />
</main>
<footer class="border-t border-gray-200 dark:border-gray-800 py-8">
<div class="container mx-auto px-4 text-center text-sm text-gray-500">
© {{ new Date().getFullYear() }} Partizap
</div>
</footer>
</div>
</template>Step 3: Create cabinet layout
Create app/layouts/cabinet.vue:
html
<template>
<div class="min-h-screen flex flex-col">
<header class="border-b border-gray-200 dark:border-gray-800">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<NuxtLink to="/">
<SharedAppLogo />
</NuxtLink>
</div>
</header>
<div class="flex-1 container mx-auto px-4 py-6 flex gap-6">
<aside class="w-56 shrink-0">
<nav class="flex flex-col gap-1">
<UButton label="Мои объявления" to="/cabinet" variant="ghost" block />
<UButton :label="$t('common.favorites')" to="/cabinet/favorites" variant="ghost" block />
<UButton label="Настройки" to="/cabinet/settings" variant="ghost" block />
</nav>
</aside>
<div class="flex-1">
<slot />
</div>
</div>
</div>
</template>Step 4: Create page stubs
Create each page as a minimal stub with definePageMeta where needed.
app/pages/index.vue:
html
<script setup lang="ts">
useSeoMeta({
title: 'Partizap — маркетплейс автозапчастей в СПб',
description: 'Купить и продать автозапчасти в Санкт-Петербурге. Поиск по марке, модели и OEM номеру.',
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold">{{ $t('common.appName') }}</h1>
<p class="mt-2 text-gray-500">Главная страница</p>
</div>
</template>app/pages/catalog/index.vue:
html
<script setup lang="ts">
useSeoMeta({
title: 'Каталог запчастей | Partizap',
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold">{{ $t('catalog.filters') }}</h1>
<p class="mt-2 text-gray-500">Каталог</p>
</div>
</template>app/pages/product/[id].vue:
html
<script setup lang="ts">
const route = useRoute()
const productId = Number(route.params.id)
useSeoMeta({
title: `Товар #${productId} | Partizap`,
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold">Товар #{{ productId }}</h1>
</div>
</template>app/pages/auth/index.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'default' })
useSeoMeta({
title: 'Вход | Partizap',
robots: 'noindex, nofollow',
})
</script>
<template>
<div class="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-2xl font-bold">{{ $t('common.login') }}</h1>
</div>
</template>app/pages/seller/[id].vue:
html
<script setup lang="ts">
const route = useRoute()
const sellerId = Number(route.params.id)
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold">Продавец #{{ sellerId }}</h1>
</div>
</template>app/pages/cabinet/index.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet' })
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Мои объявления</h1>
</div>
</template>app/pages/cabinet/products/new.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet' })
</script>
<template>
<div>
<h1 class="text-2xl font-bold">{{ $t('common.addListing') }}</h1>
</div>
</template>app/pages/cabinet/products/[id]/edit.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet' })
const route = useRoute()
const productId = Number(route.params.id)
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Редактировать #{{ productId }}</h1>
</div>
</template>app/pages/cabinet/favorites.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet' })
</script>
<template>
<div>
<h1 class="text-2xl font-bold">{{ $t('common.favorites') }}</h1>
</div>
</template>app/pages/cabinet/settings.vue:
html
<script setup lang="ts">
definePageMeta({ layout: 'cabinet' })
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Настройки</h1>
</div>
</template>Step 5: Verify routing
bash
npm run devNavigate to each route and verify:
/— default layout, header+footer visible/catalog— default layout/product/123— default layout, shows "Товар #123"/auth— default layout/seller/45— default layout/cabinet— cabinet layout with sidebar/cabinet/products/new— cabinet layout/cabinet/favorites— cabinet layout/cabinet/settings— cabinet layout
Step 6: Commit
bash
git add -A
git commit -m "feat: add routing, layouts, and page stubs"Task 10: Create API client with CSRF and cookie forwarding
Files:
- Create:
app/shared/api/client.ts - Create:
app/shared/api/types.ts - Create:
app/shared/api/useCursorPagination.ts - Create:
app/shared/api/index.ts
Step 1: Create API types
Create app/shared/api/types.ts:
ts
export interface ApiListResponse<T> {
data: T[]
meta: {
has_more: boolean
next_cursor: string | null
}
}
export interface ApiItemResponse<T> {
data: T
}
export interface ApiError {
error: {
code: string
message: string
details?: Record<string, string[]>
}
}
export interface CursorPaginationOptions {
limit?: number
}Step 2: Create API client
Create app/shared/api/client.ts:
ts
import type { ApiError } from './types'
function getCsrfToken(): string | null {
if (import.meta.server) return null
const match = document.cookie.match(/CSRF_TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : null
}
function isMutatingMethod(method: string): boolean {
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())
}
export function useApiClient() {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase as string
async function request<T>(
url: string,
options: Parameters<typeof $fetch>[1] = {},
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.headers as Record<string, string>),
}
const method = (options.method ?? 'GET').toUpperCase()
// CSRF token for mutating requests
if (isMutatingMethod(method)) {
const csrf = getCsrfToken()
if (csrf) {
headers['X-CSRF-TOKEN'] = csrf
}
}
// Forward cookies during SSR
if (import.meta.server) {
const requestHeaders = useRequestHeaders(['cookie'])
if (requestHeaders.cookie) {
headers['Cookie'] = requestHeaders.cookie
}
}
try {
return await $fetch<T>(url, {
baseURL,
credentials: 'include',
...options,
method: method as any,
headers,
})
}
catch (error: any) {
const status = error?.response?.status
const body = error?.data as ApiError | undefined
if (status === 401) {
const authStore = useAuthStore()
authStore.clear()
await navigateTo('/auth')
}
throw error
}
}
return {
get: <T>(url: string, params?: Record<string, any>) =>
request<T>(url, { method: 'GET', params }),
post: <T>(url: string, body?: any) =>
request<T>(url, { method: 'POST', body }),
put: <T>(url: string, body?: any) =>
request<T>(url, { method: 'PUT', body }),
patch: <T>(url: string, body?: any) =>
request<T>(url, { method: 'PATCH', body }),
delete: <T>(url: string) =>
request<T>(url, { method: 'DELETE' }),
}
}Step 3: Create cursor pagination composable
Create app/shared/api/useCursorPagination.ts:
ts
import type { ApiListResponse, CursorPaginationOptions } from './types'
export function useCursorPagination<T>(
fetcher: (params: Record<string, any>) => Promise<ApiListResponse<T>>,
initialParams: Record<string, any> = {},
options: CursorPaginationOptions = {},
) {
const items = ref<T[]>([]) as Ref<T[]>
const hasMore = ref(false)
const isLoading = ref(false)
const cursor = ref<string | null>(null)
const error = ref<Error | null>(null)
async function load(reset = false) {
if (isLoading.value) return
if (reset) {
items.value = []
cursor.value = null
hasMore.value = false
}
isLoading.value = true
error.value = null
try {
const params: Record<string, any> = {
...initialParams,
...(options.limit ? { limit: options.limit } : {}),
...(cursor.value ? { cursor: cursor.value } : {}),
}
const response = await fetcher(params)
items.value = reset ? response.data : [...items.value, ...response.data]
hasMore.value = response.meta.has_more
cursor.value = response.meta.next_cursor
}
catch (e: any) {
error.value = e
}
finally {
isLoading.value = false
}
}
function refresh() {
return load(true)
}
function loadMore() {
if (!hasMore.value || isLoading.value) return
return load(false)
}
return {
items: readonly(items),
hasMore: readonly(hasMore),
isLoading: readonly(isLoading),
error: readonly(error),
refresh,
loadMore,
}
}Step 4: Create public API barrel
Create app/shared/api/index.ts:
ts
export { useApiClient } from './client'
export { useCursorPagination } from './useCursorPagination'
export type {
ApiListResponse,
ApiItemResponse,
ApiError,
CursorPaginationOptions,
} from './types'Step 5: Add API base URL to runtime config
Add to nuxt.config.ts:
ts
runtimeConfig: {
public: {
apiBase: 'http://localhost:8000',
},
},Step 6: Run typecheck
bash
npx nuxi typecheckExpected: no type errors.
Step 7: Commit
bash
git add -A
git commit -m "feat: add typed API client with CSRF, SSR cookie forwarding, cursor pagination"Task 11: Create remaining entity schemas and stores
Files:
- Create:
app/entities/user/model/user.schema.ts - Create:
app/entities/category/model/category.schema.ts - Create:
app/entities/car/model/car.schema.ts - Create:
app/entities/geo/model/geo.schema.ts - Create:
app/stores/geo.ts - Create:
app/stores/favorites.ts
Step 1: User schema
Create app/entities/user/model/user.schema.ts:
ts
import { z } from 'zod'
export const accountTypeSchema = z.enum(['personal', 'business'])
export const userSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
phone: z.string().nullable(),
account_type: accountTypeSchema,
display_name: z.string().nullable(),
avatar_url: z.string().nullable(),
rating: z.number(),
reviews_count: z.number().int(),
products_count: z.number().int(),
email_verified_at: z.string().nullable(),
phone_verified_at: z.string().nullable(),
created_at: z.string(),
})
export const businessProfileSchema = z.object({
id: z.number().int().positive(),
company_name: z.string(),
inn: z.string().nullable(),
address: z.string().nullable(),
website: z.string().nullable(),
working_hours: z.string().nullable(),
is_verified: z.boolean(),
})
export type User = z.infer<typeof userSchema>
export type AccountType = z.infer<typeof accountTypeSchema>
export type BusinessProfile = z.infer<typeof businessProfileSchema>Step 2: Category schema
Create app/entities/category/model/category.schema.ts:
ts
import { z } from 'zod'
export const categoryTypeSchema = z.enum(['part', 'condition', 'attribute'])
export const categorySchema = z.object({
id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
icon: z.string().nullable(),
parent_id: z.number().int().positive().nullable(),
category_type: categoryTypeSchema,
sort_order: z.number().int(),
products_count: z.number().int(),
})
export type Category = z.infer<typeof categorySchema>
export type CategoryType = z.infer<typeof categoryTypeSchema>Step 3: Car schema
Create app/entities/car/model/car.schema.ts:
ts
import { z } from 'zod'
export const carSteeringSchema = z.enum(['left', 'right', 'both'])
export const carMakeSchema = z.object({
id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
logo_url: z.string().nullable(),
is_popular: z.boolean(),
})
export const carModelSchema = z.object({
id: z.number().int().positive(),
make_id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
})
export const carGenerationSchema = z.object({
id: z.number().int().positive(),
model_id: z.number().int().positive(),
name: z.string(),
code: z.string().nullable(),
year_from: z.number().int(),
year_to: z.number().int().nullable(),
steering: carSteeringSchema,
})
export type CarMake = z.infer<typeof carMakeSchema>
export type CarModel = z.infer<typeof carModelSchema>
export type CarGeneration = z.infer<typeof carGenerationSchema>Step 4: Geo schema
Create app/entities/geo/model/geo.schema.ts:
ts
import { z } from 'zod'
export const regionSchema = z.object({
id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
})
export const citySchema = z.object({
id: z.number().int().positive(),
region_id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
})
export const districtSchema = z.object({
id: z.number().int().positive(),
city_id: z.number().int().positive(),
name: z.string(),
slug: z.string(),
})
export const metroStationSchema = z.object({
id: z.number().int().positive(),
city_id: z.number().int().positive(),
line: z.string().nullable(),
line_color: z.string().nullable(),
name: z.string(),
})
export type Region = z.infer<typeof regionSchema>
export type City = z.infer<typeof citySchema>
export type District = z.infer<typeof districtSchema>
export type MetroStation = z.infer<typeof metroStationSchema>Step 5: Geo store
Create app/stores/geo.ts:
ts
import { defineStore } from 'pinia'
import type { City, Region } from '~/entities/geo/model/geo.schema'
interface GeoState {
currentCity: City | null
currentRegion: Region | null
regions: Region[]
}
export const useGeoStore = defineStore('geo', {
state: (): GeoState => ({
currentCity: null,
currentRegion: null,
regions: [],
}),
getters: {
cityId: (state) => state.currentCity?.id ?? null,
regionId: (state) => state.currentRegion?.id ?? null,
locationLabel: (state) => state.currentCity?.name ?? 'Все регионы',
},
actions: {
setCity(city: City | null) {
this.currentCity = city
if (import.meta.client && city) {
const cookie = useCookie('geo_city_id', { maxAge: 60 * 60 * 24 * 365 })
cookie.value = String(city.id)
}
},
setRegion(region: Region | null) {
this.currentRegion = region
},
clear() {
this.currentCity = null
this.currentRegion = null
},
},
})Step 6: Favorites store
Create app/stores/favorites.ts:
ts
import { defineStore } from 'pinia'
interface FavoritesState {
ids: Set<number>
}
export const useFavoritesStore = defineStore('favorites', {
state: (): FavoritesState => ({
ids: new Set(),
}),
getters: {
count: (state) => state.ids.size,
isFavorite: (state) => (productId: number) => state.ids.has(productId),
},
actions: {
add(productId: number) {
this.ids.add(productId)
},
remove(productId: number) {
this.ids.delete(productId)
},
toggle(productId: number) {
if (this.ids.has(productId)) {
this.ids.delete(productId)
}
else {
this.ids.add(productId)
}
},
setAll(ids: number[]) {
this.ids = new Set(ids)
},
},
})Step 7: Run typecheck
bash
npx nuxi typecheckExpected: no type errors.
Step 8: Commit
bash
git add -A
git commit -m "feat: add entity schemas (user, category, car, geo) and stores (geo, favorites)"Task 12: Setup dev tooling (ESLint, Prettier)
Files:
- Modify:
partizap-frontend/nuxt.config.ts - Create:
partizap-frontend/.prettierrc - Create:
partizap-frontend/.prettierignore
Step 1: Install ESLint module
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npx nuxi module add @nuxt/eslint
npm install -D prettier eslint-config-prettierStep 2: Create Prettier config
Create .prettierrc:
json
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}Create .prettierignore:
.nuxt
.output
node_modules
distStep 3: Add scripts to package.json
Add to scripts in package.json:
json
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write ."Step 4: Verify
bash
npm run lint
npm run formatExpected: no errors, files formatted.
Step 5: Commit
bash
git add -A
git commit -m "chore: add ESLint and Prettier configuration"Task 13: Setup Vitest
Files:
- Create:
partizap-frontend/vitest.config.ts - Create:
partizap-frontend/tests/shared/api/client.test.ts
Step 1: Install
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
npm install -D vitest @nuxt/test-utils @vue/test-utils happy-domStep 2: Create vitest config
Create vitest.config.ts:
ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
globals: true,
},
})Step 3: Write a smoke test
Create tests/shared/api/client.test.ts:
ts
import { describe, it, expect } from 'vitest'
describe('API types', () => {
it('ApiListResponse type is correctly structured', () => {
const response = {
data: [{ id: 1, name: 'test' }],
meta: { has_more: false, next_cursor: null },
}
expect(response.data).toHaveLength(1)
expect(response.meta.has_more).toBe(false)
expect(response.meta.next_cursor).toBeNull()
})
})Step 4: Add test script
Add to scripts in package.json:
json
"test": "vitest",
"test:run": "vitest run"Step 5: Run tests
bash
npm run test:runExpected: 1 test passes.
Step 6: Commit
bash
git add -A
git commit -m "chore: add Vitest with smoke test"Task 14: Create app.config.ts theme
Files:
- Create:
partizap-frontend/app/app.config.ts
Step 1: Create theme config
Create app/app.config.ts:
ts
export default defineAppConfig({
ui: {
colors: {
primary: 'blue',
neutral: 'slate',
},
},
})Step 2: Verify
bash
npm run devExpected: UI renders with blue primary color.
Step 3: Commit
bash
git add -A
git commit -m "feat: add app.config.ts with Nuxt UI theme"Task 15: Final verification
Step 1: Clean build
bash
cd /Users/dmitriy/Documents/JOB/partizap/partizap-frontend
rm -rf .nuxt .output node_modules
npm install
npm run buildExpected: build succeeds with no errors.
Step 2: Type check
bash
npx nuxi typecheckExpected: no type errors.
Step 3: Lint
bash
npm run lintExpected: no lint errors.
Step 4: Tests
bash
npm run test:runExpected: all tests pass.
Step 5: Dev server
bash
npm run devNavigate to all routes and verify layouts render correctly.
Step 6: Final commit (if any fixes needed)
bash
git add -A
git commit -m "chore: final verification fixes"Final nuxt.config.ts reference
After all tasks complete, nuxt.config.ts should look like:
ts
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
modules: [
'@nuxt/ui',
'nuxt-fsd',
'@pinia/nuxt',
'@nuxtjs/i18n',
'@nuxtjs/seo',
'@nuxt/image',
'@vueuse/nuxt',
'@nuxt/eslint',
],
css: ['~/assets/css/main.css'],
fsd: {
layers: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
segments: ['ui', 'model', 'api', 'lib', 'config'],
},
i18n: {
defaultLocale: 'ru',
locales: [{ code: 'ru', name: 'Русский' }],
vueI18n: './app/i18n/i18n.config.ts',
},
site: {
url: 'https://partizap.ru',
name: 'Partizap',
description: 'Маркетплейс автозапчастей в Санкт-Петербурге',
defaultLocale: 'ru',
},
robots: {
disallow: ['/cabinet/', '/auth'],
},
image: {
quality: 80,
formats: ['webp', 'jpeg'],
},
routeRules: {
'/cabinet/**': { ssr: false },
},
runtimeConfig: {
public: {
apiBase: 'http://localhost:8000',
},
},
typescript: {
strict: true,
typeCheck: true,
},
})