Appearance
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"
>
{{ 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)]">{{ 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">{{ city.name }}</span>
</label>
<p
v-if="filteredCities.length === 0"
class="py-2 text-center text-sm text-[var(--ui-text-muted)]"
>
{{ 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)]"
>
{{ t('geo.allCities') }} — {{ t('geo.region').toLowerCase() }} {{ 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
| File | Change |
|---|---|
app/stores/geo.ts | New state: selectedRegion, selectedCities. New getters: cityIds, hasGeoFilter, geoQueryParams, updated locationLabel. New actions: setCities. Cookies: geo_region_id + geo_city_ids. |
app/stores/geo.test.ts | New test file — 9 test cases for store behavior. |
i18n/locales/ru.json | Added common.allRussia, common.apply, geo.chooseLocation, geo.searchCity, geo.allCities. |
app/features/geo-select/ui/CitySelector.vue | Full rewrite: region dropdown + city checkboxes + search + "Вся Россия" + "Применить" button. |
app/features/search/composables/useProductSearch.ts | Use geoStore.geoQueryParams spread instead of manual city_id/region_id. |
app/features/search/composables/useProductSearch.test.ts | Updated mock, added 2 test cases for city_ids and region_id. |
app/features/search/ui/SearchBar.vue | onSubmit uses geoStore.geoQueryParams. |
app/pages/catalog/index.vue | Filters use city_ids (string) instead of city_id (number). Fallback from geo store. |
app/pages/index.vue | Uses geoStore.geoQueryParams spread. Watch on [regionId, cityIds]. |