Appearance
ProductCard: Chat Button + Location Display — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add a "Написать" chat button and city/district location line to ProductCard previews across the site.
Architecture: New useGeoLookupStore Pinia store caches city/district names by ID (fetched from existing geo endpoints). ProductCard receives seller_id and geo IDs via extended props, renders chat button (hidden on own listings) and location text. Each consumer page calls geoLookup.resolve(products) after loading data.
Tech Stack: Vue 3.5, Pinia, Nuxt 4, @nuxtjs/i18n, Vitest + @nuxt/test-utils
Task 1: Create useGeoLookupStore
Files:
- Create:
app/stores/geoLookup.ts - Test:
app/stores/geoLookup.test.ts
Step 1: Write the failing test
ts
// app/stores/geoLookup.test.ts
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { describe, it, expect, beforeEach } from 'vitest'
const { useGeoLookupStore } = await import('./geoLookup')
describe('useGeoLookupStore', () => {
beforeEach(() => {
const store = useGeoLookupStore()
store.$reset()
})
it('resolve fetches cities for region and populates cityMap', async () => {
registerEndpoint('/api/store/geo/regions/1/cities', {
method: 'GET',
handler: () => ({
data: [
{ id: 10, region_id: 1, name: 'Санкт-Петербург', slug: 'spb' },
{ id: 11, region_id: 1, name: 'Гатчина', slug: 'gatchina' },
],
}),
})
const store = useGeoLookupStore()
await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])
expect(store.getCityName(10)).toBe('Санкт-Петербург')
expect(store.getCityName(11)).toBe('Гатчина')
expect(store.getCityName(999)).toBeNull()
})
it('resolve fetches districts for city and populates districtMap', async () => {
registerEndpoint('/api/store/geo/regions/1/cities', {
method: 'GET',
handler: () => ({
data: [{ id: 10, region_id: 1, name: 'СПб', slug: 'spb' }],
}),
})
registerEndpoint('/api/store/geo/cities/10/districts', {
method: 'GET',
handler: () => ({
data: [
{ id: 100, city_id: 10, name: 'Василеостровский', slug: 'vasileostrovskiy' },
],
}),
})
const store = useGeoLookupStore()
await store.resolve([{ region_id: 1, city_id: 10, district_id: 100 }])
expect(store.getDistrictName(100)).toBe('Василеостровский')
expect(store.getDistrictName(999)).toBeNull()
})
it('does not re-fetch already resolved regions', async () => {
let callCount = 0
registerEndpoint('/api/store/geo/regions/1/cities', {
method: 'GET',
handler: () => {
callCount++
return { data: [{ id: 10, region_id: 1, name: 'СПб', slug: 'spb' }] }
},
})
const store = useGeoLookupStore()
await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])
await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])
expect(callCount).toBe(1)
})
it('skips products with null region_id/city_id', async () => {
const store = useGeoLookupStore()
await store.resolve([
{ region_id: null, city_id: null, district_id: null },
{ region_id: undefined, city_id: undefined, district_id: undefined },
])
// No error, no API calls
expect(store.getCityName(null)).toBeNull()
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/stores/geoLookup.test.ts Expected: FAIL — ./geoLookup module not found
Step 3: Write minimal implementation
ts
// app/stores/geoLookup.ts
import { defineStore } from 'pinia'
import type { City, District } from '~/entities/geo/model/geo.schema'
import type { ApiListResponse } from '~/shared/api/types'
interface GeoProduct {
region_id?: number | null
city_id?: number | null
district_id?: number | null
}
interface GeoLookupState {
cityMap: Map<number, string>
districtMap: Map<number, string>
_fetchedRegions: Set<number>
_fetchedCities: Set<number>
}
export const useGeoLookupStore = defineStore('geoLookup', {
state: (): GeoLookupState => ({
cityMap: new Map(),
districtMap: new Map(),
_fetchedRegions: new Set(),
_fetchedCities: new Set(),
}),
getters: {
getCityName: (state) => (id: number | null | undefined): string | null => {
if (!id) return null
return state.cityMap.get(id) ?? null
},
getDistrictName: (state) => (id: number | null | undefined): string | null => {
if (!id) return null
return state.districtMap.get(id) ?? null
},
},
actions: {
async resolve(products: GeoProduct[]) {
const api = useApiClient()
// Collect unique region IDs that haven't been fetched yet
const regionIds = [
...new Set(
products
.map((p) => p.region_id)
.filter((id): id is number => !!id && !this._fetchedRegions.has(id)),
),
]
// Fetch cities for each new region
await Promise.all(
regionIds.map(async (regionId) => {
try {
const res = await api.get<ApiListResponse<City>>(
`/store/geo/regions/${regionId}/cities`,
)
for (const city of res.data) {
this.cityMap.set(city.id, city.name)
}
}
catch {
// Non-critical — location just won't display
}
this._fetchedRegions.add(regionId)
}),
)
// Collect unique city IDs that have districts requested but not yet fetched
const cityIds = [
...new Set(
products
.filter((p) => p.district_id && p.city_id && !this._fetchedCities.has(p.city_id))
.map((p) => p.city_id as number),
),
]
// Fetch districts for each new city
await Promise.all(
cityIds.map(async (cityId) => {
try {
const res = await api.get<ApiListResponse<District>>(
`/store/geo/cities/${cityId}/districts`,
)
for (const district of res.data) {
this.districtMap.set(district.id, district.name)
}
}
catch {
// Non-critical
}
this._fetchedCities.add(cityId)
}),
)
},
},
})Note: Uses useApiClient() (shared layer, no auth redirect) since geo endpoints are public.
Step 4: Run test to verify it passes
Run: npx vitest run app/stores/geoLookup.test.ts Expected: PASS (all 4 tests)
Step 5: Commit
bash
git add app/stores/geoLookup.ts app/stores/geoLookup.test.ts
git commit -m "feat: add useGeoLookupStore for city/district name resolution"Task 2: Add i18n key for card chat button
Files:
- Modify:
i18n/locales/ru.json
Step 1: Add the i18n key
In the existing "product" section (if it exists, find it; if not — create it), add "cardWrite" key. Alternatively, reuse "seller.writeMessage" which is already "Написать". Check if a "product" section exists first.
Search for "product" section in ru.json. If none, add inside "product" block near other product keys. The existing seller.writeMessage is "Написать" — we can reuse that key in ProductCard to avoid duplication.
Decision: Reuse seller.writeMessage ("Написать") — it's already the exact text needed. No i18n changes required for the chat button.
For location display, no new key needed — it's just city/district names from the store.
Step 2: Commit (skip if no changes)
No commit needed — no new keys required.
Task 3: Extend ProductCard with chat button and location line
Files:
- Modify:
app/entities/product/ui/ProductCard.vue
Step 1: Read current ProductCard (already read above — lines 1-68)
Step 2: Update ProductCard
Full updated component:
html
<script setup lang="ts">
import type { Conversation } from '~/entities/conversation/model/conversation.schema'
import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { ApiItemResponse } from '~/shared/api/types'
import { NuxtLink } from '#components'
const props = defineProps<{
product: Product & {
images?: readonly ProductImage[]
seller_id?: number
city_id?: number | null
region_id?: number | null
district_id?: number | null
primary_category_id?: number | null
}
showStatus?: boolean
to?: string
}>()
const authStore = useAuthStore()
const geoLookup = useGeoLookupStore()
const { t } = useI18n()
const imageUrl = computed(() => {
const primary =
props.product.images?.find((i) => i.is_primary) ?? props.product.images?.[0]
return primary?.medium_webp ?? primary?.medium_jpeg ?? primary?.thumbnail_webp ?? null
})
const formattedPrice = computed(() => {
return new Intl.NumberFormat('ru-RU').format(props.product.price) + ' \u20BD'
})
const link = computed(() => {
if (props.to === '') return ''
return props.to ?? `/product/${props.product.id}`
})
// --- Chat button ---
const isOwnProduct = computed(() => {
if (!props.product.seller_id || !authStore.user) return true
return props.product.seller_id === authStore.user.id
})
const chatLoading = ref(false)
async function openChat() {
if (!authStore.isAuthenticated) {
await navigateTo('/auth/login')
return
}
chatLoading.value = true
try {
const api = useApi()
const res = await api.post<ApiItemResponse<Conversation>>(
'/vendor/conversations',
{ product_id: props.product.id },
)
await navigateTo(`/cabinet/messages/${res.data.id}`)
}
finally {
chatLoading.value = false
}
}
// --- Location ---
const locationLabel = computed(() => {
const parts: string[] = []
const city = geoLookup.getCityName(props.product.city_id)
if (city) parts.push(city)
const district = geoLookup.getDistrictName(props.product.district_id)
if (district) parts.push(district)
return parts.join(', ')
})
</script>
<template>
<component
:is="link ? NuxtLink : 'div'"
v-bind="link ? { to: link } : {}"
class="block overflow-hidden rounded-lg border border-gray-200 transition-shadow dark:border-gray-700"
:class="link ? 'hover:shadow-md cursor-pointer' : 'opacity-80'"
>
<div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-800">
<img
v-if="imageUrl"
:src="imageUrl"
:alt="product.title"
class="h-full w-full object-cover"
loading="lazy"
/>
<div
v-else
class="flex h-full w-full items-center justify-center text-gray-400"
>
<UIcon name="i-heroicons-photo" class="h-12 w-12" />
</div>
<ProductStatusBadge
v-if="showStatus"
:status="product.status"
class="absolute left-2 top-2"
/>
<FavoriteButton
:product-id="product.id"
class="absolute right-2 top-2 bg-white/80 dark:bg-gray-900/80 rounded-full backdrop-blur-sm"
/>
</div>
<div class="p-3">
<h3 class="line-clamp-2 min-h-[2.5rem] text-lg font-medium">{{ product.title }}</h3>
<p class="mt-1 text-xl font-bold">{{ formattedPrice }}</p>
<button
v-if="!isOwnProduct"
type="button"
class="mt-2 flex items-center gap-1 text-sm font-medium text-primary hover:underline"
:disabled="chatLoading"
@click.prevent.stop="openChat"
>
<UIcon name="i-lucide-message-circle" class="h-4 w-4" />
{{ t('seller.writeMessage') }}
</button>
<div
v-if="locationLabel"
class="mt-1 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
>
<UIcon name="i-lucide-map-pin" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ locationLabel }}</span>
</div>
</div>
</component>
</template>Step 3: Run lint to verify
Run: npm run lint Expected: PASS (no ESLint errors)
Step 4: Commit
bash
git add app/entities/product/ui/ProductCard.vue
git commit -m "feat(ProductCard): add chat button and location display"Task 4: Update consumer pages — extend types and call geoLookup.resolve()
Files:
- Modify:
app/pages/catalog/index.vue - Modify:
app/pages/index.vue - Modify:
app/pages/seller/[id].vue - Modify:
app/pages/cabinet/favorites.vue - Modify:
app/pages/cabinet/index.vue
Each page needs two changes:
- Extend the
ProductWithImagestype withseller_id,region_id,district_id - Call
geoLookup.resolve()after products are loaded
4a: app/pages/catalog/index.vue
Step 1: Extend type (line 69)
Change:
ts
type ProductWithImages = Product & { images?: ProductImage[]; city_id?: number }To:
ts
type ProductWithImages = Product & {
images?: ProductImage[]
seller_id?: number
city_id?: number | null
region_id?: number | null
district_id?: number | null
}Step 2: Add geo resolve after load (after line 81)
After the useCursorPagination block, add a watcher:
ts
const geoLookup = useGeoLookupStore()
watch(items, (products) => {
if (products.length) geoLookup.resolve(products)
}, { immediate: true })4b: app/pages/index.vue
Step 1: Extend type (line 31)
Change:
ts
type ProductWithImages = Product & { images?: ProductImage[] }To:
ts
type ProductWithImages = Product & {
images?: ProductImage[]
seller_id?: number
city_id?: number | null
region_id?: number | null
district_id?: number | null
}Step 2: Add geo resolve (after line 75)
After const listings = computed(...):
ts
const geoLookup = useGeoLookupStore()
watch(listings, (products) => {
if (products.length) geoLookup.resolve(products)
}, { immediate: true })4c: app/pages/seller/[id].vue
Step 1: Extend type (line 32)
Change:
ts
type ProductWithImages = Product & { images?: ProductImage[]; city_id?: number }To:
ts
type ProductWithImages = Product & {
images?: ProductImage[]
seller_id?: number
city_id?: number | null
region_id?: number | null
district_id?: number | null
}Step 2: Replace broken city name resolution (lines 43-49)
Remove the broken geoStore.currentCity code and use geoLookup instead:
ts
const geoLookup = useGeoLookupStore()
watch(products, (items) => {
if (items.length) geoLookup.resolve(items)
}, { immediate: true })Also remove the standalone cityName ref (line 96) and replace its template usage (line 150) with geoLookup.getCityName(seller.city_id):
In template, change:
html
<div v-if="cityName" ...>
...
<span>{{ cityName }}</span>
</div>To:
html
<div v-if="geoLookup.getCityName(seller.city_id)" ...>
...
<span>{{ geoLookup.getCityName(seller.city_id) }}</span>
</div>And resolve seller's city on mount via geoLookup.resolve(): In onMounted, replace the broken geoStore logic with:
ts
onMounted(async () => {
refresh()
if (seller.value?.city_id) {
// Resolve seller city name — need region_id which we don't have on SellerPublic.
// Products from this seller will trigger geo resolve via the watcher above.
}
})Note: SellerPublic doesn't have region_id, so seller city name will only appear once products load (which provide region_id). This is acceptable — the broken code before didn't work either.
4d: app/pages/cabinet/favorites.vue
Step 1: Add geo resolve
After the onMounted fetchAll, resolve geo for loaded items:
ts
const geoLookup = useGeoLookupStore()
watch(() => favoritesStore.items, (products) => {
if (products.length) geoLookup.resolve(products)
}, { immediate: true })Note: favoritesStore.items returns Product[] — the favorite products from API may not include region_id/city_id/district_id. If they don't, the watcher is a no-op and no location shows. The chat button still works if seller_id is present.
4e: app/pages/cabinet/index.vue
No changes needed — these are own products. The chat button hides automatically (isOwnProduct is true because seller_id === authStore.user.id). Location will show if the data has geo fields.
Step 3: Run lint
Run: npm run lint:fix Expected: PASS
Step 4: Commit
bash
git add app/pages/catalog/index.vue app/pages/index.vue app/pages/seller/\[id\].vue app/pages/cabinet/favorites.vue
git commit -m "feat: wire geoLookup.resolve() into product list pages"Task 5: Run full verification
Step 1: Run lint
Run: npm run lint Expected: PASS — no errors
Step 2: Run typecheck
Run: npm run typecheck Expected: PASS
Step 3: Run tests
Run: npm run test:run Expected: PASS — all tests including new geoLookup tests
Step 4: Run dev server and verify visually
Run: npm run dev Check:
/catalog— cards show location text (if products have city_id), chat button visible on non-own products/(homepage) — same behavior on recent listings/cabinet— own products: no chat button, location still shows- Click "Написать" on card — navigates to chat or login if not authenticated
- Click.prevent.stop works — clicking chat button doesn't navigate to product page
Step 5: Final commit if any fixes needed