Skip to content

Geo Multi-Select Implementation Plan

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

Goal: Replace single-city header picker with region + multi-city selector, update geo store, and integrate with catalog/search/homepage.

Architecture: Refactor useGeoStore to hold selectedRegion + selectedCities[]. Rewrite CitySelector.vue modal with region dropdown + city checkboxes. Update all consumers (catalog, search, homepage) to pass region_id or city_ids (comma-separated) to the API.

Tech Stack: Nuxt 4, Vue 3.5, Pinia, Nuxt UI v3, Vitest + @nuxt/test-utils


Task 1: Refactor Geo Store

Files:

  • Modify: app/stores/geo.ts
  • Create: app/stores/geo.test.ts

Step 1: Write failing test for new geo store

Create app/stores/geo.test.ts:

ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { useGeoStore } = await import('./geo')

describe('useGeoStore', () => {
  beforeEach(() => {
    const store = useGeoStore()
    store.$reset()
  })

  it('initial state has no selection', () => {
    const store = useGeoStore()
    expect(store.selectedRegion).toBeNull()
    expect(store.selectedCities).toEqual([])
    expect(store.regionId).toBeUndefined()
    expect(store.cityIds).toEqual([])
    expect(store.hasGeoFilter).toBe(false)
    expect(store.locationLabel).toBe('Вся Россия')
  })

  it('setRegion sets region and clears cities', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Ленинградская область', slug: 'len-obl' })
    expect(store.regionId).toBe(5)
    expect(store.selectedCities).toEqual([])
    expect(store.hasGeoFilter).toBe(true)
    expect(store.locationLabel).toBe('Ленинградская область')
  })

  it('setCities stores selected cities', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    store.setCities([
      { id: 1, region_id: 5, name: 'Санкт-Петербург', slug: 'spb' },
      { id: 7, region_id: 5, name: 'Выборг', slug: 'vyborg' },
    ])
    expect(store.cityIds).toEqual([1, 7])
    expect(store.hasGeoFilter).toBe(true)
  })

  it('locationLabel shows single city name', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    store.setCities([{ id: 1, region_id: 5, name: 'Санкт-Петербург', slug: 'spb' }])
    expect(store.locationLabel).toBe('Санкт-Петербург')
  })

  it('locationLabel shows first city + count for multiple', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    store.setCities([
      { id: 1, region_id: 5, name: 'Санкт-Петербург', slug: 'spb' },
      { id: 7, region_id: 5, name: 'Выборг', slug: 'vyborg' },
      { id: 12, region_id: 5, name: 'Гатчина', slug: 'gatchina' },
    ])
    expect(store.locationLabel).toBe('Санкт-Петербург + 2 города')
  })

  it('clear resets everything', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    store.setCities([{ id: 1, region_id: 5, name: 'СПб', slug: 'spb' }])
    store.clear()
    expect(store.selectedRegion).toBeNull()
    expect(store.selectedCities).toEqual([])
    expect(store.hasGeoFilter).toBe(false)
    expect(store.locationLabel).toBe('Вся Россия')
  })

  it('geoQueryParams returns region_id when no cities selected', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    expect(store.geoQueryParams).toEqual({ region_id: 5 })
  })

  it('geoQueryParams returns city_ids when cities selected', () => {
    const store = useGeoStore()
    store.setRegion({ id: 5, name: 'Лен. область', slug: 'len-obl' })
    store.setCities([
      { id: 1, region_id: 5, name: 'СПб', slug: 'spb' },
      { id: 7, region_id: 5, name: 'Выборг', slug: 'vyborg' },
    ])
    expect(store.geoQueryParams).toEqual({ city_ids: '1,7' })
  })

  it('geoQueryParams returns empty object when no filter', () => {
    const store = useGeoStore()
    expect(store.geoQueryParams).toEqual({})
  })
})

Step 2: Run test to verify it fails

Run: npm run test:run -- app/stores/geo.test.ts Expected: FAIL — properties selectedRegion, selectedCities, setCities, hasGeoFilter, geoQueryParams don't exist.

Step 3: Implement the new geo store

Replace app/stores/geo.ts with:

ts
import { useCookie } from '#imports'
import { defineStore } from 'pinia'

import type { City, Region } from '~/entities/geo/model/geo.schema'

interface GeoState {
  selectedRegion: Region | null
  selectedCities: City[]
  regions: Region[]
}

export const useGeoStore = defineStore('geo', {
  state: (): GeoState => ({
    selectedRegion: null,
    selectedCities: [],
    regions: [],
  }),
  getters: {
    regionId: (state): number | undefined => state.selectedRegion?.id,
    cityIds: (state): number[] => state.selectedCities.map(c => c.id),
    hasGeoFilter: (state): boolean => state.selectedRegion !== null,
    locationLabel: (state): string => {
      if (!state.selectedRegion) return 'Вся Россия'
      if (state.selectedCities.length === 0) return state.selectedRegion.name
      if (state.selectedCities.length === 1) return state.selectedCities[0]!.name
      const first = state.selectedCities[0]!.name
      const rest = state.selectedCities.length - 1
      return `${first} + ${rest} города`
    },
    geoQueryParams: (state): Record<string, string | number> => {
      if (!state.selectedRegion) return {}
      if (state.selectedCities.length > 0) {
        return { city_ids: state.selectedCities.map(c => c.id).join(',') }
      }
      return { region_id: state.selectedRegion.id }
    },
  },
  actions: {
    setRegion(region: Region | null) {
      this.selectedRegion = region
      this.selectedCities = []
      this._persistCookies()
    },
    setCities(cities: City[]) {
      this.selectedCities = cities
      this._persistCookies()
    },
    setRegions(regions: Region[]) {
      this.regions = regions
    },
    clear() {
      this.selectedRegion = null
      this.selectedCities = []
      this._persistCookies()
    },
    _persistCookies() {
      if (!import.meta.client) return
      const regionCookie = useCookie('geo_region_id', { maxAge: 60 * 60 * 24 * 365 })
      const citiesCookie = useCookie('geo_city_ids', { maxAge: 60 * 60 * 24 * 365 })
      regionCookie.value = this.selectedRegion ? String(this.selectedRegion.id) : null
      citiesCookie.value = this.selectedCities.length > 0
        ? this.selectedCities.map(c => c.id).join(',')
        : null
    },
  },
})

Step 4: Run test to verify it passes

Run: npm run test:run -- app/stores/geo.test.ts Expected: ALL PASS

Step 5: Commit

bash
git add app/stores/geo.ts app/stores/geo.test.ts
git commit -m "feat(geo): refactor store for multi-city selection"

Task 2: Add i18n keys for geo multi-select

Files:

  • Modify: i18n/locales/ru.json

Step 1: Add new i18n keys

In i18n/locales/ru.json, update the "geo" section and add missing keys in "common":

In "common" section, add:

json
"allRussia": "Вся Россия",
"apply": "Применить"

In "geo" section, replace "chooseCity" and add new keys:

json
"chooseLocation": "Выберите местоположение",
"searchCity": "Поиск города...",
"allCities": "Все города",
"citiesCount": "+ {count} города"

Step 2: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(geo): add i18n keys for multi-select location picker"

Task 3: Rewrite CitySelector.vue with multi-city checkboxes

Files:

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

Step 1: Rewrite CitySelector.vue

Replace entire content of app/features/geo-select/ui/CitySelector.vue:

html
<script setup lang="ts">
import type { City } from '~/entities/geo/model/geo.schema'

const { t } = useI18n()
const geoStore = useGeoStore()
const geo = useGeoCascade()

const isOpen = ref(false)
const searchQuery = ref('')
const checkedCityIds = ref<Set<number>>(new Set())

async function open() {
  isOpen.value = true
  if (geo.regions.value.length === 0) {
    await geo.fetchRegions()
  }
  // Restore current selection into local state
  if (geoStore.selectedRegion) {
    geo.selectedRegionId.value = geoStore.selectedRegion.id
    checkedCityIds.value = new Set(geoStore.cityIds)
  }
}

function toggleCity(cityId: number) {
  const next = new Set(checkedCityIds.value)
  if (next.has(cityId)) {
    next.delete(cityId)
  } else {
    next.add(cityId)
  }
  checkedCityIds.value = next
}

function clearCities() {
  checkedCityIds.value = new Set()
}

function apply() {
  const region = geo.regions.value.find(r => r.id === geo.selectedRegionId.value)
  if (region) {
    geoStore.setRegion(region)
    const selected = geo.cities.value.filter(c => checkedCityIds.value.has(c.id))
    geoStore.setCities(selected)
  }
  isOpen.value = false
}

function selectAllRussia() {
  geoStore.clear()
  geo.reset()
  checkedCityIds.value = new Set()
  isOpen.value = false
}

// Reset checked cities when region changes
watch(() => geo.selectedRegionId.value, () => {
  checkedCityIds.value = new Set()
})

const filteredCities = computed(() => {
  const q = searchQuery.value.toLowerCase().trim()
  if (!q) return geo.cities.value
  return geo.cities.value.filter(c => c.name.toLowerCase().includes(q))
})

const regionItems = computed(() =>
  geo.regions.value.map(r => ({ label: r.name, value: r.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.chooseLocation')">
    <template #body>
      <div class="flex flex-col gap-4">
        <!-- Search -->
        <UInput
          v-model="searchQuery"
          :placeholder="t('geo.searchCity')"
          icon="i-lucide-search"
          autocomplete="off"
        />

        <!-- All Russia -->
        <UButton
          variant="ghost"
          class="justify-start"
          icon="i-lucide-globe"
          :label="t('common.allRussia')"
          @click="selectAllRussia"
        />

        <!-- Region selector -->
        <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>

        <!-- City checkboxes -->
        <div v-if="geo.selectedRegionId.value">
          <div class="mb-2 flex items-center justify-between">
            <span class="text-sm font-medium text-[var(--ui-text)]">&#123;&#123; t('geo.city') }}</span>
            <UButton
              v-if="checkedCityIds.size > 0"
              variant="link"
              size="xs"
              :label="t('geo.allCities')"
              @click="clearCities"
            />
          </div>

          <div v-if="geo.loadingCities.value" class="flex justify-center py-4">
            <UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-[var(--ui-text-muted)]" />
          </div>

          <div v-else class="max-h-60 space-y-1 overflow-y-auto">
            <label
              v-for="city in filteredCities"
              :key="city.id"
              class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-[var(--ui-bg-elevated)]"
            >
              <input
                type="checkbox"
                :checked="checkedCityIds.has(city.id)"
                class="h-4 w-4 rounded border-[var(--ui-border)] text-[var(--ui-primary)]"
                @change="toggleCity(city.id)"
              />
              <span class="text-sm">&#123;&#123; city.name }}</span>
            </label>

            <p
              v-if="filteredCities.length === 0"
              class="py-2 text-center text-sm text-[var(--ui-text-muted)]"
            >
              &#123;&#123; t('search.noResults') }}
            </p>
          </div>

          <p
            v-if="checkedCityIds.size === 0 && !geo.loadingCities.value && geo.cities.value.length > 0"
            class="mt-1 text-xs text-[var(--ui-text-muted)]"
          >
            &#123;&#123; t('geo.allCities') }} — &#123;&#123; t('geo.region').toLowerCase() }} &#123;&#123; geo.regions.value.find(r => r.id === geo.selectedRegionId.value)?.name }}
          </p>
        </div>

        <!-- Apply button -->
        <UButton
          :label="t('common.apply')"
          :disabled="!geo.selectedRegionId.value"
          block
          @click="apply"
        />
      </div>
    </template>
  </UModal>
</template>

Step 2: Verify manually

Run: npm run dev Expected: Open site, click the geo button in header, see region dropdown + city checkboxes. Selecting region + cities → "Применить" → label updates in header.

Step 3: Commit

bash
git add app/features/geo-select/ui/CitySelector.vue
git commit -m "feat(geo): rewrite CitySelector with multi-city checkboxes"

Task 4: Update useProductSearch to use geoQueryParams

Files:

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

Step 1: Write failing test for new geo params

Add a new test case in useProductSearch.test.ts. First, update the mock at the top of the file to use the new store shape. Replace the existing mockNuxtImport('useGeoStore'...) with:

ts
let mockGeoStore = {
  cityIds: [] as number[],
  regionId: undefined as number | undefined,
  hasGeoFilter: false,
  geoQueryParams: {} as Record<string, string | number>,
}

mockNuxtImport('useGeoStore', () => () => mockGeoStore)

Update beforeEach to reset the mock:

ts
beforeEach(() => {
  vi.clearAllMocks()
  mockGeoStore = {
    cityIds: [],
    regionId: undefined,
    hasGeoFilter: false,
    geoQueryParams: {},
  }
})

Add new test cases:

ts
it('passes city_ids when geo store has selected cities', async () => {
  mockGeoStore.geoQueryParams = { city_ids: '1,7' }
  mockGeoStore.hasGeoFilter = true
  mockGet.mockResolvedValue({ data: [] })

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

  expect(mockGet).toHaveBeenCalledWith('/store/products/search', {
    q: 'фара',
    limit: 5,
    city_ids: '1,7',
  })
})

it('passes region_id when geo store has only region', async () => {
  mockGeoStore.geoQueryParams = { region_id: 5 }
  mockGeoStore.hasGeoFilter = true
  mockGet.mockResolvedValue({ data: [] })

  const { query, search } = useProductSearch()
  query.value = 'бампер'
  await search()

  expect(mockGet).toHaveBeenCalledWith('/store/products/search', {
    q: 'бампер',
    limit: 5,
    region_id: 5,
  })
})

Also update the existing test 'calls /store/products/search with query param' — it should still pass since geoQueryParams is {} by default (no geo params added).

Step 2: Run test to verify it fails

Run: npm run test:run -- app/features/search/composables/useProductSearch.test.ts Expected: FAIL — the composable still uses old cityId/regionId properties.

Step 3: Update useProductSearch.ts

Replace 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 geoStore = useGeoStore()

  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
    }

    const params: Record<string, unknown> = { q, limit: 5, ...geoStore.geoQueryParams }

    isSearching.value = true
    try {
      const response = await api.get<ApiListResponse<SearchResult>>(
        '/store/products/search',
        params,
      )
      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: npm run test:run -- app/features/search/composables/useProductSearch.test.ts Expected: ALL PASS

Step 5: Commit

bash
git add app/features/search/composables/useProductSearch.ts app/features/search/composables/useProductSearch.test.ts
git commit -m "feat(geo): update product search to use geoQueryParams"

Task 5: Update SearchBar.vue to pass city_ids in URL

Files:

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

Step 1: Update onSubmit in SearchBar.vue

In app/features/search/ui/SearchBar.vue, replace the onSubmit function (lines 20-28):

Old:

ts
function onSubmit() {
  if (!query.value.trim()) return
  isOpen.value = false
  const routeQuery: Record<string, string> = { q: query.value.trim() }
  if (geoStore.cityId) routeQuery.city_id = String(geoStore.cityId)
  else if (geoStore.regionId) routeQuery.region_id = String(geoStore.regionId)
  router.push({ path: '/catalog', query: routeQuery })
  clear()
}

New:

ts
function onSubmit() {
  if (!query.value.trim()) return
  isOpen.value = false
  const routeQuery: Record<string, string> = { q: query.value.trim() }
  const geoParams = geoStore.geoQueryParams
  for (const [key, val] of Object.entries(geoParams)) {
    routeQuery[key] = String(val)
  }
  router.push({ path: '/catalog', query: routeQuery })
  clear()
}

Step 2: Commit

bash
git add app/features/search/ui/SearchBar.vue
git commit -m "feat(geo): update SearchBar to pass city_ids in URL"

Task 6: Update Catalog page filters

Files:

  • Modify: app/pages/catalog/index.vue

Step 1: Update catalog filters initialization

In app/pages/catalog/index.vue, replace region_id and city_id in the filters reactive object (lines 29-30):

Old:

ts
  region_id: Number(route.query.region_id) || geoStore.regionId || undefined,
  city_id: Number(route.query.city_id) || geoStore.cityId || undefined,

New:

ts
  region_id: Number(route.query.region_id) || undefined,
  city_ids: (route.query.city_ids as string) || undefined,

Also add fallback from geo store after the initial declaration. Add after const filters = reactive({...}):

ts
// Fallback: apply geo store params if no geo in URL
if (!filters.region_id && !filters.city_ids && geoStore.hasGeoFilter) {
  const geoParams = geoStore.geoQueryParams
  if ('city_ids' in geoParams) filters.city_ids = String(geoParams.city_ids)
  else if ('region_id' in geoParams) filters.region_id = Number(geoParams.region_id)
}

Step 2: Update resetFilters

In the resetFilters function, replace region_id and city_id with:

ts
  region_id: undefined,
  city_ids: undefined,

Remove the old city_id line.

Step 3: Commit

bash
git add app/pages/catalog/index.vue
git commit -m "feat(geo): update catalog page to use city_ids filter"

Task 7: Update Homepage geo-aware listings

Files:

  • Modify: app/pages/index.vue

Step 1: Update homepage API call

In app/pages/index.vue, replace the listings data fetch (lines 69-74):

Old:

ts
const {
  data: listingsData,
  status: listingsStatus,
  refresh: refreshListings,
} = await useAsyncData('home-listings', () => {
  const params: Record<string, unknown> = { sort: 'date_desc', limit: 8 }
  if (geoStore.cityId) params.city_id = geoStore.cityId
  else if (geoStore.regionId) params.region_id = geoStore.regionId
  return api.get<ApiListResponse<ProductWithImages>>('/store/products', params)
})

New:

ts
const {
  data: listingsData,
  status: listingsStatus,
  refresh: refreshListings,
} = await useAsyncData('home-listings', () => {
  const params: Record<string, unknown> = { sort: 'date_desc', limit: 8, ...geoStore.geoQueryParams }
  return api.get<ApiListResponse<ProductWithImages>>('/store/products', params)
})

Step 2: Update the watch trigger

Replace the watch (lines 79-82):

Old:

ts
watch(
  () => geoStore.cityId,
  () => refreshListings(),
)

New:

ts
watch(
  () => [geoStore.regionId, geoStore.cityIds],
  () => refreshListings(),
  { deep: true },
)

Step 3: Commit

bash
git add app/pages/index.vue
git commit -m "feat(geo): update homepage listings to use geoQueryParams"

Task 8: Run full test suite and lint

Step 1: Run all tests

Run: npm run test:run Expected: ALL PASS

Step 2: Run linter

Run: npm run lint:fix Expected: No errors (or auto-fixed)

Step 3: Run typecheck

Run: npm run typecheck Expected: No type errors

Step 4: Commit any lint fixes

bash
git add -A
git commit -m "chore: lint fixes after geo multi-select"

(Only if there are changes to commit.)


Summary of Changes

FileChange
app/stores/geo.tsNew state: selectedRegion, selectedCities. New getters: cityIds, hasGeoFilter, geoQueryParams, updated locationLabel. New actions: setCities. Cookies: geo_region_id + geo_city_ids.
app/stores/geo.test.tsNew test file — 9 test cases for store behavior.
i18n/locales/ru.jsonAdded common.allRussia, common.apply, geo.chooseLocation, geo.searchCity, geo.allCities.
app/features/geo-select/ui/CitySelector.vueFull rewrite: region dropdown + city checkboxes + search + "Вся Россия" + "Применить" button.
app/features/search/composables/useProductSearch.tsUse geoStore.geoQueryParams spread instead of manual city_id/region_id.
app/features/search/composables/useProductSearch.test.tsUpdated mock, added 2 test cases for city_ids and region_id.
app/features/search/ui/SearchBar.vueonSubmit uses geoStore.geoQueryParams.
app/pages/catalog/index.vueFilters use city_ids (string) instead of city_id (number). Fallback from geo store.
app/pages/index.vueUses geoStore.geoQueryParams spread. Watch on [regionId, cityIds].