Appearance
DEV-201: Suggest Search + Relevance Sort
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Переключить автокомплит поиска на лёгкий эндпоинт /store/products/suggest и добавить сортировку по релевантности в каталог.
Architecture: Заменяем тип ответа автокомплита с полного Product на ProductSuggestion { id, title, price, thumbnail }. В каталоге добавляем relevance в сортировку с авто-переключением при наличии поискового запроса.
Tech Stack: Nuxt 4, Vue 3, Zod, Vitest, Nuxt UI v3
Task 1: ProductSuggestion schema
Files:
- Modify:
app/entities/product/model/product.schema.ts:152-163
Step 1: Add Zod schema and type export
After export type ProductForm (line 163), add:
ts
// --- Suggest (lightweight search autocomplete) ---
export const productSuggestionSchema = z.object({
id: z.number(),
title: z.string(),
price: z.number(),
thumbnail: z.string().nullable(),
})
export type ProductSuggestion = z.infer<typeof productSuggestionSchema>Step 2: Verify typecheck
Run: npx nuxi typecheck Expected: PASS (no new errors)
Step 3: Commit
bash
git add app/entities/product/model/product.schema.ts
git commit -m "feat(DEV-201): add ProductSuggestion schema"Task 2: Switch useProductSearch to /suggest
Files:
- Modify:
app/features/search/composables/useProductSearch.ts - Modify:
app/features/search/composables/useProductSearch.test.ts
Step 1: Update tests first
Replace the full test file content. Key changes:
- Remove
mockGeoStore(suggest endpoint has no geo params) - Change endpoint assertions:
/store/products/search→/store/products/suggest - Change limit assertions:
5→6 - Update mock response shape to
ProductSuggestion(addthumbnail)
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()
})
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/suggest with query param', async () => {
mockGet.mockResolvedValue({
data: [{ id: 1, title: 'Фара BMW', price: 5000, thumbnail: null }],
})
const { query, results, search } = useProductSearch()
query.value = 'фара'
await search()
expect(mockGet).toHaveBeenCalledWith('/store/products/suggest', { q: 'фара', limit: 6 })
expect(results.value).toHaveLength(1)
expect(results.value[0]?.title).toBe('Фара BMW')
})
it('does not pass geo params to suggest endpoint', async () => {
mockGet.mockResolvedValue({ data: [] })
const { query, search } = useProductSearch()
query.value = 'фара'
await search()
const callArgs = mockGet.mock.calls[0]![1] as Record<string, unknown>
expect(callArgs).not.toHaveProperty('city_ids')
expect(callArgs).not.toHaveProperty('region_id')
})
it('clears results on clear()', async () => {
mockGet.mockResolvedValue({
data: [{ id: 1, title: 'Фара', price: 5000, thumbnail: null }],
})
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('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 tests to verify they fail
Run: npx vitest run app/features/search/composables/useProductSearch.test.ts Expected: FAIL — endpoint still /store/products/search, limit still 5, geo params still present
Step 3: Update implementation
Replace app/features/search/composables/useProductSearch.ts:
ts
import { ref, readonly } from 'vue'
import type { ProductSuggestion } from '~/entities/product/model/product.schema'
export function useProductSearch() {
const api = useApi()
const query = ref('')
const results = ref<ProductSuggestion[]>([])
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<{ data: ProductSuggestion[] }>(
'/store/products/suggest',
{ q, limit: 6 },
)
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 tests to verify they pass
Run: npx vitest run app/features/search/composables/useProductSearch.test.ts Expected: PASS (all 6 tests)
Step 5: Commit
bash
git add app/features/search/composables/useProductSearch.ts app/features/search/composables/useProductSearch.test.ts
git commit -m "feat(DEV-201): switch autocomplete to /store/products/suggest"Task 3: Update useSearchAutocomplete tests
Files:
- Modify:
app/features/search/composables/useSearchAutocomplete.test.ts
Step 1: Update mock data shape
In the mock setup, change mockResults items from full Product to ProductSuggestion shape. The mock for useProductSearch returns results — update any test that pushes data into mockResults to use { id, title, price, thumbnail } instead of { id, title, price }.
Specifically, in the test 'onInput triggers debounced search and opens dropdown when results exist', the mock results push should include thumbnail: null.
Step 2: Run tests
Run: npx vitest run app/features/search/composables/useSearchAutocomplete.test.ts Expected: PASS (no functional changes to useSearchAutocomplete, only data shape)
Step 3: Commit (if changes were needed)
bash
git add app/features/search/composables/useSearchAutocomplete.test.ts
git commit -m "test(DEV-201): align autocomplete tests with ProductSuggestion shape"Task 4: Update SearchBar + HeroSearchBar dropdowns
Files:
- Modify:
app/features/search/ui/SearchBar.vue:62-72 - Modify:
app/features/search/ui/HeroSearchBar.vue:86-96
Step 1: Update SearchBar.vue dropdown image
Replace the image block (lines 62-72):
vue
<div class="h-10 w-10 shrink-0 overflow-hidden rounded bg-gray-100 dark:bg-gray-800">
<img
v-if="product.thumbnail"
:src="product.thumbnail"
: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>Step 2: Update HeroSearchBar.vue dropdown image
Same replacement in HeroSearchBar.vue (lines 86-96) — identical change.
Step 3: Verify dev server renders correctly
Run: npm run dev — open browser, type in search bar, verify dropdown shows thumbnail/placeholder + title + price.
Step 4: Commit
bash
git add app/features/search/ui/SearchBar.vue app/features/search/ui/HeroSearchBar.vue
git commit -m "feat(DEV-201): use ProductSuggestion.thumbnail in search dropdowns"Task 5: Add relevance sort to catalog
Files:
- Modify:
app/pages/catalog/index.vue:101-116 - Modify:
i18n/locales/ru.json
Step 1: Add i18n key
In i18n/locales/ru.json, after "sortByPriceDesc" (line 117), add:
json
"sortByRelevance": "По релевантности",Step 2: Make sortOptions computed and add auto-switch logic
In catalog/index.vue, replace the static sortOptions (lines 101-105) and onHeroSearch (lines 113-116):
ts
const sortOptions = computed(() => {
const base = [
{ label: t('catalog.sortByDate'), value: 'date_desc' },
{ label: t('catalog.sortByPriceAsc'), value: 'price_asc' },
{ label: t('catalog.sortByPriceDesc'), value: 'price_desc' },
]
if (filters.q) {
base.unshift({ label: t('catalog.sortByRelevance'), value: 'relevance' })
}
return base
})
function onHeroSearch(q: string) {
filters.q = q || undefined
filters.sort = q ? 'relevance' : 'date_desc'
applyFilters(refresh)
}Note: need to add import { computed } from 'vue' if not already auto-imported (Nuxt auto-imports it, so no explicit import needed).
Step 3: Verify in browser
- Open
/catalog— sort dropdown: 3 options (no relevance) - Type search query → sort auto-switches to "По релевантности", dropdown: 4 options
- Clear search → sort returns to "По дате", dropdown: 3 options
- Manually select "Сначала дешёвые" while search is active → stays on price_asc
Step 4: Commit
bash
git add app/pages/catalog/index.vue i18n/locales/ru.json
git commit -m "feat(DEV-201): add relevance sort option in catalog"Task 6: Final validation
Step 1: Run linter
Run: npm run lint Expected: PASS
Step 2: Run typecheck
Run: npm run typecheck Expected: PASS
Step 3: Run all tests
Run: npm run test:run Expected: PASS
Step 4: Commit any fixes if needed
Summary
| Task | What | Estimate |
|---|---|---|
| 1 | ProductSuggestion schema | 5 min |
| 2 | useProductSearch → /suggest | 15 min |
| 3 | useSearchAutocomplete tests | 5 min |
| 4 | SearchBar + HeroSearchBar dropdowns | 10 min |
| 5 | Relevance sort in catalog | 15 min |
| 6 | Final validation | 5 min |
| Total | ~55 min |