Skip to content

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: 56
  • Update mock response shape to ProductSuggestion (add thumbnail)
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

  1. Open /catalog — sort dropdown: 3 options (no relevance)
  2. Type search query → sort auto-switches to "По релевантности", dropdown: 4 options
  3. Clear search → sort returns to "По дате", dropdown: 3 options
  4. 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

TaskWhatEstimate
1ProductSuggestion schema5 min
2useProductSearch → /suggest15 min
3useSearchAutocomplete tests5 min
4SearchBar + HeroSearchBar dropdowns10 min
5Relevance sort in catalog15 min
6Final validation5 min
Total~55 min