Appearance
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
Task 1: Add i18n keys for homepage and search
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">{{ product.title }}</p>
<p class="text-xs font-semibold text-[var(--ui-text-highlighted)]">
{{ formattedPrice(product.price) }}
</p>
</div>
</li>
</ul>
<div v-else class="px-3 py-4 text-center text-sm text-[var(--ui-text-muted)]">
{{ 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"
>
{{ 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">
{{ 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">{{ 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">{{ 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">{{ t('home.listings.title') }}</h2>
<NuxtLink
to="/catalog"
class="text-sm font-medium text-primary hover:underline"
>
{{ 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)]">{{ 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 dropdownAdd 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
| Task | Description | Creates | Tests |
|---|---|---|---|
| 1 | i18n keys | — | JSON validation |
| 2 | useProductSearch composable | composable + test | 5 unit tests |
| 3 | SearchBar component | Vue component | lint check |
| 4 | CitySelector + header integration | Vue component + layout mod | dev server |
| 5 | Homepage (hero + categories + listings) | page rewrite | dev server |
| 6 | Lint + typecheck + tests | — | full suite |
| 7 | Manual smoke test | — | manual checklist |
| 8 | Update CLAUDE.md | docs | — |
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.