Skip to content

Homepage + Header Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement the homepage (hero + categories + recent listings) and enhance the default layout header with search autocomplete and city selector.

Architecture: Feature-Sliced Design. New features/search slice for search composable + SearchBar component. Homepage sections inline in index.vue reusing existing YmmSelect, ProductCard, useYmmCascade, useGeoCascade. City selector uses useGeoStore + useGeoCascade in a modal.

Tech Stack: Nuxt 4.3, Vue 3.5, Nuxt UI v3, Tailwind CSS 4, Pinia, Vitest, Zod, i18n


Files:

  • Modify: i18n/locales/ru.json

Step 1: Add the i18n keys

Add two new top-level sections to ru.json"home" and "search":

json
"home": {
  "hero": {
    "title": "Найдите запчасть для вашего авто",
    "submit": "Подобрать"
  },
  "categories": {
    "title": "Категории запчастей"
  },
  "listings": {
    "title": "Новые объявления",
    "viewAll": "Смотреть все",
    "empty": "Объявлений пока нет",
    "emptyAction": "Разместить первое"
  }
},
"search": {
  "placeholder": "Поиск запчастей...",
  "noResults": "Ничего не найдено"
}

Insert these after the "common" block (before "auth").

Also add to "geo" section:

json
"chooseCity": "Выбрать город",
"allRegions": "Все регионы"

Step 2: Verify JSON is valid

Run: node -e "JSON.parse(require('fs').readFileSync('i18n/locales/ru.json', 'utf8')); console.log('Valid JSON')" Expected: Valid JSON

Step 3: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(i18n): add homepage and search translation keys"

Task 2: Create useProductSearch composable

Files:

  • Create: app/features/search/composables/useProductSearch.ts
  • Create: app/features/search/composables/useProductSearch.test.ts

Step 1: Write the failing test

Create app/features/search/composables/useProductSearch.test.ts:

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
}))

const { useProductSearch } = await import('./useProductSearch')

describe('useProductSearch', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('returns expected composable shape', () => {
    const result = useProductSearch()
    expect(result).toHaveProperty('query')
    expect(result).toHaveProperty('results')
    expect(result).toHaveProperty('isSearching')
    expect(result).toHaveProperty('search')
    expect(result).toHaveProperty('clear')
  })

  it('does not search for queries shorter than 2 characters', async () => {
    const { query, search } = useProductSearch()
    query.value = 'a'
    await search()
    expect(mockGet).not.toHaveBeenCalled()
  })

  it('calls /store/products/search with query param', async () => {
    mockGet.mockResolvedValue({
      data: [{ id: 1, title: 'Фара BMW', price: 5000 }],
    })

    const { query, results, search } = useProductSearch()
    query.value = 'фара'
    await search()

    expect(mockGet).toHaveBeenCalledWith('/store/products/search', { q: 'фара', limit: 5 })
    expect(results.value).toHaveLength(1)
    expect(results.value[0].title).toBe('Фара BMW')
  })

  it('clears results on clear()', async () => {
    mockGet.mockResolvedValue({
      data: [{ id: 1, title: 'Фара', price: 5000 }],
    })

    const { query, results, search, clear } = useProductSearch()
    query.value = 'фара'
    await search()
    expect(results.value).toHaveLength(1)

    clear()
    expect(results.value).toHaveLength(0)
    expect(query.value).toBe('')
  })

  it('sets isSearching during API call', async () => {
    let resolvePromise: (v: unknown) => void
    mockGet.mockReturnValue(new Promise((r) => { resolvePromise = r }))

    const { query, isSearching, search } = useProductSearch()
    query.value = 'фара'
    const searchPromise = search()

    expect(isSearching.value).toBe(true)

    resolvePromise!({ data: [] })
    await searchPromise

    expect(isSearching.value).toBe(false)
  })

  it('handles API errors gracefully', async () => {
    mockGet.mockRejectedValue(new Error('Network error'))

    const { query, results, isSearching, search } = useProductSearch()
    query.value = 'фара'
    await search()

    expect(results.value).toHaveLength(0)
    expect(isSearching.value).toBe(false)
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/search/composables/useProductSearch.test.ts Expected: FAIL — module not found

Step 3: Write the implementation

Create app/features/search/composables/useProductSearch.ts:

ts
import { ref, readonly } from 'vue'

import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { ApiListResponse } from '~/shared/api/types'

type SearchResult = Product & { images?: ProductImage[] }

export function useProductSearch() {
  const api = useApi()

  const query = ref('')
  const results = ref<SearchResult[]>([])
  const isSearching = ref(false)

  async function search() {
    const q = query.value.trim()
    if (q.length < 2) {
      results.value = []
      return
    }

    isSearching.value = true
    try {
      const response = await api.get<ApiListResponse<SearchResult>>(
        '/store/products/search',
        { q, limit: 5 },
      )
      results.value = response.data
    }
    catch {
      results.value = []
    }
    finally {
      isSearching.value = false
    }
  }

  function clear() {
    query.value = ''
    results.value = []
  }

  return {
    query,
    results: readonly(results),
    isSearching: readonly(isSearching),
    search,
    clear,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/search/composables/useProductSearch.test.ts Expected: All 5 tests PASS

Step 5: Commit

bash
git add app/features/search/
git commit -m "feat(search): add useProductSearch composable with tests"

Task 3: Create SearchBar component

Files:

  • Create: app/features/search/ui/SearchBar.vue

Step 1: Write the SearchBar component

Create app/features/search/ui/SearchBar.vue:

html
<script setup lang="ts">
import type { Product, ProductImage } from '~/entities/product/model/product.schema'

const { t } = useI18n()
const router = useRouter()
const { query, results, isSearching, search, clear } = useProductSearch()

const isOpen = ref(false)

const debouncedSearch = useDebounceFn(async () => {
  await search()
  isOpen.value = results.value.length > 0 || query.value.length >= 2
}, 300)

function onInput() {
  debouncedSearch()
}

function onSubmit() {
  if (!query.value.trim()) return
  isOpen.value = false
  router.push({ path: '/catalog', query: { q: query.value.trim() } })
  clear()
}

function goToProduct(product: Product & { images?: ProductImage[] }) {
  isOpen.value = false
  router.push(`/product/${product.id}`)
  clear()
}

function onBlur() {
  // Delay to allow click on result
  setTimeout(() => {
    isOpen.value = false
  }, 200)
}

function onFocus() {
  if (results.value.length > 0) {
    isOpen.value = true
  }
}

const formattedPrice = (price: number) =>
  new Intl.NumberFormat('ru-RU').format(price) + ' \u20BD'

const getImageUrl = (product: Product & { images?: ProductImage[] }) => {
  const primary = product.images?.find(i => i.is_primary) ?? product.images?.[0]
  return primary?.thumbnail_webp ?? primary?.thumbnail_jpeg ?? null
}
</script>

<template>
  <div class="relative w-full max-w-md">
    <form @submit.prevent="onSubmit">
      <UInput
        v-model="query"
        :placeholder="t('search.placeholder')"
        icon="i-lucide-search"
        :loading="isSearching"
        autocomplete="off"
        @input="onInput"
        @focus="onFocus"
        @blur="onBlur"
        @keydown.escape="isOpen = false"
      />
    </form>

    <!-- Dropdown results -->
    <div
      v-if="isOpen"
      class="absolute top-full left-0 z-50 mt-1 w-full rounded-lg border border-[var(--ui-border)] bg-[var(--ui-bg)] shadow-lg"
    >
      <ul v-if="results.length > 0">
        <li
          v-for="product in results"
          :key="product.id"
          class="flex cursor-pointer items-center gap-3 px-3 py-2 transition-colors hover:bg-[var(--ui-bg-elevated)]"
          @mousedown.prevent="goToProduct(product)"
        >
          <div class="h-10 w-10 shrink-0 overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
            <img
              v-if="getImageUrl(product)"
              :src="getImageUrl(product)!"
              :alt="product.title"
              class="h-full w-full object-cover"
            />
            <div v-else class="flex h-full w-full items-center justify-center">
              <UIcon name="i-heroicons-photo" class="h-5 w-5 text-gray-400" />
            </div>
          </div>
          <div class="min-w-0 flex-1">
            <p class="truncate text-sm font-medium">&#123;&#123; product.title }}</p>
            <p class="text-xs font-semibold text-[var(--ui-text-highlighted)]">
              &#123;&#123; formattedPrice(product.price) }}
            </p>
          </div>
        </li>
      </ul>
      <div v-else class="px-3 py-4 text-center text-sm text-[var(--ui-text-muted)]">
        &#123;&#123; t('search.noResults') }}
      </div>
    </div>
  </div>
</template>

Step 2: Verify no lint errors

Run: npx eslint app/features/search/ Expected: No errors

Step 3: Commit

bash
git add app/features/search/ui/SearchBar.vue
git commit -m "feat(search): add SearchBar component with autocomplete dropdown"

Task 4: Create CitySelector component and integrate into header

Files:

  • Create: app/features/geo-select/ui/CitySelector.vue
  • Modify: app/layouts/default.vue

Step 1: Create the CitySelector component

Create app/features/geo-select/ui/CitySelector.vue:

html
<script setup lang="ts">
const { t } = useI18n()
const geoStore = useGeoStore()
const geo = useGeoCascade()

const isOpen = ref(false)

async function open() {
  isOpen.value = true
  if (geo.regions.value.length === 0) {
    await geo.fetchRegions()
  }
}

function selectCity(cityId: number) {
  const city = geo.cities.value.find(c => c.id === cityId)
  const region = geo.regions.value.find(r => r.id === geo.selectedRegionId.value)
  if (city) {
    geoStore.setCity(city)
  }
  if (region) {
    geoStore.setRegion(region)
  }
  isOpen.value = false
}

function clearCity() {
  geoStore.clear()
  geo.reset()
  isOpen.value = false
}

const regionItems = computed(() =>
  geo.regions.value.map(r => ({ label: r.name, value: r.id })),
)

const cityItems = computed(() =>
  geo.cities.value.map(c => ({ label: c.name, value: c.id })),
)
</script>

<template>
  <UButton
    variant="ghost"
    size="sm"
    icon="i-lucide-map-pin"
    @click="open"
  >
    &#123;&#123; geoStore.locationLabel }}
  </UButton>

  <UModal v-model:open="isOpen" :title="t('geo.chooseCity')">
    <template #body>
      <div class="flex flex-col gap-4">
        <UFormField :label="t('geo.region')">
          <USelectMenu
            v-model="geo.selectedRegionId.value"
            :items="regionItems"
            :placeholder="t('geo.selectRegion')"
            :loading="geo.loadingRegions.value"
            value-key="value"
            label-key="label"
          />
        </UFormField>

        <UFormField v-if="geo.selectedRegionId.value" :label="t('geo.city')">
          <USelectMenu
            v-model="geo.selectedCityId.value"
            :items="cityItems"
            :placeholder="t('geo.selectCity')"
            :loading="geo.loadingCities.value"
            value-key="value"
            label-key="label"
          />
        </UFormField>

        <div class="flex justify-end gap-2">
          <UButton variant="ghost" :label="t('common.allRegions')" @click="clearCity" />
          <UButton
            :label="t('common.save')"
            :disabled="!geo.selectedCityId.value"
            @click="selectCity(geo.selectedCityId.value!)"
          />
        </div>
      </div>
    </template>
  </UModal>
</template>

Step 2: Integrate SearchBar and CitySelector into default layout

Modify app/layouts/default.vue. Add between Logo and desktop nav:

html
<!-- Add after the <NuxtLink to="/"><SharedAppLogo /></NuxtLink> line -->
<CitySelector class="hidden md:flex" />
<SearchBar class="hidden md:flex flex-1 mx-4" />

In the mobile header section (.flex.md\\:hidden), add a search icon button that expands:

html
<!-- Add search toggle in mobile icons, before the menu button -->
<UButton
  variant="ghost"
  size="sm"
  icon="i-lucide-search"
  :aria-label="t('search.placeholder')"
  @click="mobileSearchOpen = !mobileSearchOpen"
/>

Add mobile search bar below header (shown conditionally):

html
<!-- Below the header bar, inside <header> -->
<div v-if="mobileSearchOpen" class="border-t border-gray-200 dark:border-gray-800 px-4 py-2 md:hidden">
  <SearchBar class="w-full" />
</div>

Add mobileSearchOpen ref in <script setup>:

ts
const mobileSearchOpen = ref(false)

Also add CitySelector and search to mobile slideover menu.

Step 3: Verify dev server works

Run: npm run dev — open http://localhost:3000, check header shows search bar and city selector.

Step 4: Commit

bash
git add app/features/geo-select/ui/CitySelector.vue app/layouts/default.vue
git commit -m "feat(header): add search bar and city selector to default layout"

Task 5: Implement homepage — Hero with YMM filter

Files:

  • Modify: app/pages/index.vue

Step 1: Replace the homepage stub with Hero section

Replace the entire content of app/pages/index.vue:

html
<script setup lang="ts">
import type { Category } from '~/entities/category/model/category.schema'
import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { ApiListResponse } from '~/shared/api/types'

const { t } = useI18n()
const api = useApi()
const router = useRouter()

useSeoMeta({
  title: 'Partizap — маркетплейс автозапчастей в СПб',
  description: 'Купить и продать автозапчасти в Санкт-Петербурге. Поиск по марке, модели и OEM номеру.',
})

// --- Hero: YMM filter ---
const ymm = useYmmCascade()
onMounted(() => ymm.fetchMakes())

function submitYmm() {
  const query: Record<string, string> = {}
  const sel = ymm.getSelection()
  if (sel.make_id) query.make_id = String(sel.make_id)
  if (sel.model_id) query.model_id = String(sel.model_id)
  if (sel.generation_id) query.generation_id = String(sel.generation_id)
  router.push({ path: '/catalog', query })
}

// --- Categories ---
type ProductWithImages = Product & { images?: ProductImage[] }

const { data: categoriesData } = await useAsyncData('home-categories', () =>
  api.get<ApiListResponse<Category>>('/store/categories', { type: 'part' }),
)

const categories = computed(() =>
  (categoriesData.value?.data ?? []).filter(c => c.parent_id === null),
)

// --- Recent listings ---
const { data: listingsData, status: listingsStatus } = await useAsyncData('home-listings', () =>
  api.get<ApiListResponse<ProductWithImages>>('/store/products', {
    sort: 'date_desc',
    limit: 8,
  }),
)

const listings = computed(() => listingsData.value?.data ?? [])
</script>

<template>
  <div>
    <!-- Hero -->
    <section class="bg-[var(--ui-bg-elevated)] py-10 md:py-16">
      <div class="container mx-auto px-4 text-center">
        <h1 class="text-3xl font-bold md:text-4xl">
          &#123;&#123; t('home.hero.title') }}
        </h1>

        <div class="mx-auto mt-8 max-w-3xl">
          <div class="flex flex-col gap-3 sm:flex-row sm:items-end">
            <USelectMenu
              v-model="ymm.selectedMakeId.value"
              :items="ymm.makes.value.map(m => ({ label: m.name, value: m.id }))"
              :placeholder="t('ymm.selectMake')"
              :loading="ymm.loadingMakes.value"
              value-key="value"
              label-key="label"
              class="w-full sm:flex-1"
            />
            <USelectMenu
              v-model="ymm.selectedModelId.value"
              :items="ymm.models.value.map(m => ({ label: m.name, value: m.id }))"
              :placeholder="t('ymm.selectModel')"
              :loading="ymm.loadingModels.value"
              :disabled="!ymm.selectedMakeId.value"
              value-key="value"
              label-key="label"
              class="w-full sm:flex-1"
            />
            <USelectMenu
              v-model="ymm.selectedGenerationId.value"
              :items="ymm.generations.value.map(g => ({ label: `${g.name} ${g.year_from}–${g.year_to ?? '...'}`, value: g.id }))"
              :placeholder="t('ymm.selectGeneration')"
              :loading="ymm.loadingGenerations.value"
              :disabled="!ymm.selectedModelId.value"
              value-key="value"
              label-key="label"
              class="w-full sm:flex-1"
            />
            <UButton
              :label="t('home.hero.submit')"
              size="lg"
              :disabled="!ymm.selectedMakeId.value"
              class="w-full sm:w-auto"
              @click="submitYmm"
            />
          </div>
        </div>
      </div>
    </section>

    <!-- Categories -->
    <section v-if="categories.length > 0" class="py-10 md:py-12">
      <div class="container mx-auto px-4">
        <h2 class="mb-6 text-2xl font-bold">&#123;&#123; t('home.categories.title') }}</h2>
        <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
          <NuxtLink
            v-for="cat in categories"
            :key="cat.id"
            :to="{ path: '/catalog', query: { category_id: cat.id } }"
            class="flex flex-col items-center gap-2 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-bg)] p-4 transition-shadow hover:shadow-md"
          >
            <UIcon
              :name="cat.icon ?? 'i-lucide-cog'"
              class="h-8 w-8 text-[var(--ui-text-muted)]"
            />
            <span class="text-center text-sm font-medium">&#123;&#123; cat.name }}</span>
          </NuxtLink>
        </div>
      </div>
    </section>

    <!-- Recent Listings -->
    <section class="pb-10 md:pb-16">
      <div class="container mx-auto px-4">
        <div class="mb-6 flex items-center justify-between">
          <h2 class="text-2xl font-bold">&#123;&#123; t('home.listings.title') }}</h2>
          <NuxtLink
            to="/catalog"
            class="text-sm font-medium text-primary hover:underline"
          >
            &#123;&#123; t('home.listings.viewAll') }}
          </NuxtLink>
        </div>

        <!-- Loading skeleton -->
        <div v-if="listingsStatus === 'pending'" class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
          <div
            v-for="i in 4"
            :key="i"
            class="overflow-hidden rounded-lg border border-[var(--ui-border)]"
          >
            <USkeleton class="aspect-[4/3] w-full" />
            <div class="p-3">
              <USkeleton class="mb-2 h-4 w-3/4" />
              <USkeleton class="h-5 w-1/2" />
            </div>
          </div>
        </div>

        <!-- Products grid -->
        <div v-else-if="listings.length > 0" class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
          <ProductCard
            v-for="product in listings"
            :key="product.id"
            :product="product"
          />
        </div>

        <!-- Empty state -->
        <div v-else class="py-12 text-center">
          <p class="mb-4 text-[var(--ui-text-muted)]">&#123;&#123; t('home.listings.empty') }}</p>
          <UButton :label="t('home.listings.emptyAction')" to="/cabinet/products/new" />
        </div>
      </div>
    </section>
  </div>
</template>

Step 2: Verify dev server renders homepage

Run: npm run dev — open http://localhost:3000 Expected: Hero with YMM selects, categories grid, recent listings

Step 3: Commit

bash
git add app/pages/index.vue
git commit -m "feat(home): implement homepage with hero, categories, and recent listings"

Task 6: Lint, typecheck, and verify

Step 1: Run linter

Run: npm run lint:fix Expected: No errors (warnings ok)

Step 2: Run typecheck

Run: npm run typecheck Expected: No type errors

Step 3: Run all tests

Run: npm run test:run Expected: All tests pass

Step 4: Fix any issues found in steps 1-3

If lint/typecheck/tests reveal issues, fix them before proceeding.

Step 5: Commit fixes if any

bash
git add -A
git commit -m "fix: resolve lint and type errors in homepage implementation"

Task 7: Manual smoke test

Step 1: Desktop smoke test

Run npm run dev, open http://localhost:3000 in desktop browser:

  • [ ] Header: logo, city selector, search bar, nav buttons visible
  • [ ] Search: type 2+ chars → dropdown appears with results (or "not found")
  • [ ] Search: click result → navigates to /product/:id
  • [ ] Search: press Enter → navigates to /catalog?q=...
  • [ ] City selector: click → modal with region/city selects
  • [ ] Hero: YMM selects cascade (make → model → generation)
  • [ ] Hero: "Подобрать" → navigates to /catalog?make_id=...
  • [ ] Categories: grid of category cards from API
  • [ ] Categories: click → navigates to /catalog?category_id=...
  • [ ] Recent listings: 8 product cards with images/prices
  • [ ] Recent listings: "Смотреть все" → /catalog
  • [ ] Dark mode: toggle → all sections render correctly

Step 2: Mobile smoke test

Resize browser to 375px width:

  • [ ] Header: compact, search icon visible
  • [ ] Search icon: click → search bar expands below header
  • [ ] YMM filter: stacks vertically
  • [ ] Categories: 2-column grid
  • [ ] Product cards: 2-column grid

Step 3: Document any issues found

If issues found, fix and commit with descriptive message.


Task 8: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Step 1: Update Progress section

Add to the Progress checklist:

markdown
- [x] Homepage (hero YMM filter, categories, recent listings)
- [x] Header enhancement (search autocomplete, city selector)

Step 2: Update Project Structure if needed

Add features/search/ to the tree if not present:

├── features/
│   ├── search/
│   │   ├── composables/useProductSearch.ts  # Debounced search with results
│   │   └── ui/SearchBar.vue                 # Input + autocomplete dropdown

Add CitySelector.vue under geo-select/ui/.

Step 3: Commit

bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with homepage and search feature"

Summary

TaskDescriptionCreatesTests
1i18n keysJSON validation
2useProductSearch composablecomposable + test5 unit tests
3SearchBar componentVue componentlint check
4CitySelector + header integrationVue component + layout moddev server
5Homepage (hero + categories + listings)page rewritedev server
6Lint + typecheck + testsfull suite
7Manual smoke testmanual checklist
8Update CLAUDE.mddocs

Estimated commits: 6-8 Dependencies: Task 1 → Tasks 2-5 (i18n keys needed). Tasks 2-3 sequential (composable before component). Task 4 depends on Task 3. Task 5 independent of 3-4. Task 6-8 after all implementation.