Skip to content

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-frontend

Select default options when prompted.

Step 2: Configure pnpm (if using pnpm)

Create .npmrc:

shamefully-hoist=true

Step 3: Install dependencies and verify

bash
cd partizap-frontend
npm install
npm run dev

Expected: 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 typecheck

Expected: 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 tailwindcss

Step 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 dev

Expected: 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-fsd

Step 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/ui

Step 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/nuxt

Step 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: &#123;&#123; 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/i18n

Step 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">&#123;&#123; $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/seo

Step 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">&#123;&#123; $t('common.appName') }}</p>
    <UButton :label="$t('common.search')" class="mt-4" />
  </div>
</template>

Step 4: Verify

bash
npm run dev

Open 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/image

Step 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 zod

Step 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">&#123;&#123; $t('common.appName') }} — viewport: &#123;&#123; 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">
        &copy; &#123;&#123; 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">&#123;&#123; $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">&#123;&#123; $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">Товар #&#123;&#123; 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">&#123;&#123; $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">Продавец #&#123;&#123; 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">&#123;&#123; $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">Редактировать #&#123;&#123; 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">&#123;&#123; $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 dev

Navigate 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"

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 typecheck

Expected: 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 typecheck

Expected: 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-prettier

Step 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
dist

Step 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 format

Expected: 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-dom

Step 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:run

Expected: 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 dev

Expected: 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 build

Expected: build succeeds with no errors.

Step 2: Type check

bash
npx nuxi typecheck

Expected: no type errors.

Step 3: Lint

bash
npm run lint

Expected: no lint errors.

Step 4: Tests

bash
npm run test:run

Expected: all tests pass.

Step 5: Dev server

bash
npm run dev

Navigate 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,
  },
})